@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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * PHP string-literal harvester — the optional third pass.
3
+ *
4
+ * Given the source and the PHP islands already found by `findIslands()`, this locates the byte ranges of every
5
+ * string literal *value* inside PHP code that is eligible for class sorting, applying these structural rules:
6
+ *
7
+ * - Only the *value* of a `key => value` pair is eligible; array keys are never sorted.
8
+ * - Bare list-style elements (`['a', 'b']`) and scalar assignments (`const X = '...'`) are values.
9
+ * - Strings that are part of a concatenation expression (`'btn-' . $v`) are skipped — a literal joined to dynamic
10
+ * code may be a partial class fragment, and reordering it would corrupt the rendered string.
11
+ * - Double-quoted strings containing interpolation (`"p-4 {$x}"`) are skipped, mirroring the HTML side's
12
+ * conservatism around dynamic content.
13
+ * - Strings whose body contains a backslash escape are skipped, so escape sequences can never be mangled.
14
+ * - Heredoc/nowdoc and backtick (shell-exec) strings are never harvested.
15
+ *
16
+ * This pass is opt-in per file (the caller decides which files are class-string holders); it does NOT judge whether
17
+ * a given string "looks like" Tailwind classes — within a matched file, every eligible value is sorted.
18
+ *
19
+ * @see islands.ts - first pass; produces the islands consumed here.
20
+ * @see transform.ts - splices the sorted strings back via the shared byte-replacement path.
21
+ */
22
+
23
+ import type { Island } from './islands.ts';
24
+
25
+ /**
26
+ * Inner byte range of a sortable string literal — the span *between* the quotes, in original-source offsets.
27
+ */
28
+ export interface PhpStringRange {
29
+ /**
30
+ * Offset of the first character inside the quotes.
31
+ */
32
+ start: number;
33
+ /**
34
+ * Offset just past the last character inside the quotes (exclusive).
35
+ */
36
+ end: number;
37
+ }
38
+
39
+ const isIdentStart = (c: string) => /[A-Za-z_€-￿]/.test(c);
40
+ const isIdent = (c: string) => /[A-Za-z0-9_€-￿]/.test(c);
41
+
42
+ interface Token {
43
+ kind: 'string' | 'arrow' | 'dot' | 'other';
44
+ /**
45
+ * For `string` tokens: inner range and whether it must be skipped.
46
+ */
47
+ start?: number;
48
+ end?: number;
49
+ skip?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Find every sortable string-literal value within the given PHP islands.
54
+ *
55
+ * @param src Original template source.
56
+ * @param islands Island ranges from `findIslands()`.
57
+ * @returns Inner ranges of eligible string values, in document order.
58
+ *
59
+ * @example
60
+ * const islands = findIslands(`<?php $x = 'z a'; ?>`);
61
+ * findSortablePhpStrings(`<?php $x = 'z a'; ?>`, islands); // [{ start: 11, end: 14 }]
62
+ */
63
+ export function findSortablePhpStrings(src: string, islands: Island[]): PhpStringRange[] {
64
+ const ranges: PhpStringRange[] = [];
65
+ for (const isl of islands) {
66
+ const tokens = tokenizeIsland(src, isl.start, isl.end);
67
+ for (let k = 0; k < tokens.length; k++) {
68
+ const token = tokens[k];
69
+ if (token.kind !== 'string' || token.skip) continue;
70
+
71
+ const next = tokens[k + 1];
72
+ const prev = tokens[k - 1];
73
+
74
+ // Array key (`'left' => ...`) — never sorted.
75
+ if (next && next.kind === 'arrow') continue;
76
+ // Part of a concatenation expression (`'btn-' . $v` / `$v . 'suffix'`).
77
+ if ((next && next.kind === 'dot') || (prev && prev.kind === 'dot')) continue;
78
+
79
+ ranges.push({ start: token.start!, end: token.end! });
80
+ }
81
+ }
82
+ return ranges;
83
+ }
84
+
85
+ /**
86
+ * Tokenize one PHP island into the minimal token stream needed for value classification: string literals
87
+ * (with their inner range and a skip flag), the `=>` arrow, the `.` concatenation operator, and a coalesced `other`
88
+ * marker for everything else. Whitespace and comments are dropped so adjacency is judged across them.
89
+ */
90
+ function tokenizeIsland(src: string, start: number, end: number): Token[] {
91
+ const tokens: Token[] = [];
92
+ const pushOther = () => {
93
+ if (tokens.length === 0 || tokens[tokens.length - 1].kind !== 'other') tokens.push({ kind: 'other' });
94
+ };
95
+
96
+ let i = start;
97
+ while (i < end) {
98
+ const c = src[i];
99
+
100
+ // Whitespace.
101
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v') {
102
+ i++;
103
+ continue;
104
+ }
105
+
106
+ // Line comments (`//`, `#`) — but `#[` opens a PHP 8 attribute, which is code.
107
+ if (c === '/' && src[i + 1] === '/') {
108
+ i = skipLineComment(src, i + 2, end);
109
+ continue;
110
+ }
111
+ if (c === '#' && src[i + 1] !== '[') {
112
+ i = skipLineComment(src, i + 1, end);
113
+ continue;
114
+ }
115
+
116
+ // Block comment.
117
+ if (c === '/' && src[i + 1] === '*') {
118
+ const close = src.indexOf('*/', i + 2);
119
+ i = close === -1 || close + 2 > end ? end : close + 2;
120
+ continue;
121
+ }
122
+
123
+ // Heredoc / nowdoc — never harvested; skip the whole construct.
124
+ if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
125
+ const here = skipHeredoc(src, i + 3, end);
126
+ if (here !== -1) {
127
+ i = here;
128
+ pushOther();
129
+ continue;
130
+ }
131
+ }
132
+
133
+ // Quoted strings.
134
+ if (c === "'") {
135
+ const close = scanQuoted(src, i + 1, "'", end);
136
+ const inner = src.slice(i + 1, close);
137
+ tokens.push({ kind: 'string', start: i + 1, end: close, skip: inner.includes('\\') });
138
+ i = close + 1;
139
+ continue;
140
+ }
141
+ if (c === '"') {
142
+ const close = scanQuoted(src, i + 1, '"', end);
143
+ const inner = src.slice(i + 1, close);
144
+ // Skip interpolation (any unescaped `$`) and escapes.
145
+ tokens.push({ kind: 'string', start: i + 1, end: close, skip: hasInterpolationOrEscape(inner) });
146
+ i = close + 1;
147
+ continue;
148
+ }
149
+ if (c === '`') {
150
+ // Shell-exec string — never a class list.
151
+ i = scanQuoted(src, i + 1, '`', end) + 1;
152
+ pushOther();
153
+ continue;
154
+ }
155
+
156
+ // Operators that matter for classification.
157
+ if (c === '=' && src[i + 1] === '>') {
158
+ tokens.push({ kind: 'arrow' });
159
+ i += 2;
160
+ continue;
161
+ }
162
+ if (c === '.') {
163
+ tokens.push({ kind: 'dot' });
164
+ i++;
165
+ continue;
166
+ }
167
+
168
+ // Everything else (identifiers, punctuation, the `<?php`/`?>` tags themselves).
169
+ pushOther();
170
+ if (isIdentStart(c)) {
171
+ i++;
172
+ while (i < end && isIdent(src[i])) i++;
173
+ } else {
174
+ i++;
175
+ }
176
+ }
177
+
178
+ return tokens;
179
+ }
180
+
181
+ /**
182
+ * Scan a quoted string body; `i` is just past the open quote. Returns the offset of the closing quote (or `end`).
183
+ */
184
+ function scanQuoted(src: string, i: number, quote: string, end: number): number {
185
+ while (i < end) {
186
+ const c = src[i];
187
+ if (c === '\\') {
188
+ i += 2;
189
+ continue;
190
+ }
191
+ if (c === quote) return i;
192
+ i++;
193
+ }
194
+ return end;
195
+ }
196
+
197
+ /**
198
+ * Skip a `//`/`#` line comment body, ending at a newline or `?>`. Returns the offset to resume scanning from.
199
+ */
200
+ function skipLineComment(src: string, i: number, end: number): number {
201
+ while (i < end) {
202
+ if (src[i] === '\n') return i + 1;
203
+ if (src[i] === '?' && src[i + 1] === '>') return i;
204
+ i++;
205
+ }
206
+ return end;
207
+ }
208
+
209
+ /**
210
+ * True if a double-quoted body contains interpolation (an unescaped `$`) or any escape sequence.
211
+ */
212
+ function hasInterpolationOrEscape(body: string): boolean {
213
+ for (let i = 0; i < body.length; i++) {
214
+ const c = body[i];
215
+ if (c === '\\') return true;
216
+ if (c === '$') return true;
217
+ }
218
+ return false;
219
+ }
220
+
221
+ /**
222
+ * Skip a heredoc/nowdoc; `i` is just past `<<<`. Returns the offset past the closing identifier line,
223
+ * or -1 if `<<<` isn't followed by a valid heredoc identifier. Mirrors the boundary rules of the island lexer.
224
+ */
225
+ function skipHeredoc(src: string, i: number, end: number): number {
226
+ while (i < end && (src[i] === ' ' || src[i] === '\t')) i++;
227
+
228
+ let quote = '';
229
+ if (src[i] === "'" || src[i] === '"') {
230
+ quote = src[i];
231
+ i++;
232
+ }
233
+
234
+ if (i >= end || !isIdentStart(src[i])) return -1;
235
+ const idStart = i;
236
+ while (i < end && isIdent(src[i])) i++;
237
+ const id = src.slice(idStart, i);
238
+
239
+ if (quote) {
240
+ if (src[i] !== quote) return -1;
241
+ i++;
242
+ }
243
+
244
+ while (i < end && src[i] === '\r') i++;
245
+ if (src[i] !== '\n') return -1;
246
+ i++;
247
+
248
+ while (i < end) {
249
+ let j = i;
250
+ while (j < end && (src[j] === ' ' || src[j] === '\t')) j++;
251
+ if (src.startsWith(id, j)) {
252
+ const k = j + id.length;
253
+ if (k >= end || !isIdent(src[k])) return k;
254
+ }
255
+ const nl = src.indexOf('\n', i);
256
+ if (nl === -1 || nl >= end) return end;
257
+ i = nl + 1;
258
+ }
259
+ return end;
260
+ }
package/src/sorter.ts CHANGED
@@ -12,19 +12,19 @@ import type { SortFn } from './transform.ts';
12
12
  * Options for constructing the official Tailwind sorter.
13
13
  */
14
14
  export interface SorterOptions {
15
- /**
16
- * Tailwind v4 CSS entry point (`@import "tailwindcss"`, `@theme`, etc.).
17
- */
18
- stylesheet: string;
19
- /**
20
- * Base directory for resolving relative paths. Default: `cwd`.
21
- */
22
- base?: string;
15
+ /**
16
+ * Tailwind v4 CSS entry point.
17
+ */
18
+ stylesheet: string;
19
+ /**
20
+ * Base directory for resolving relative paths. Default: `cwd`.
21
+ */
22
+ base?: string;
23
23
  }
24
24
 
25
25
  /**
26
- * Create a `SortFn` backed by the official `prettier-plugin-tailwindcss` sorting engine, configured with
27
- * the project's Tailwind v4 stylesheet so custom `@theme` tokens and `@utility` classes sort correctly.
26
+ * Create a `SortFn` backed by the official `prettier-plugin-tailwindcss` sorting engine,
27
+ * configured with the project's Tailwind v4 stylesheet so custom tokens and classes sort correctly.
28
28
  *
29
29
  * Requires `prettier-plugin-tailwindcss` >= 0.8 (the `/sorter` entrypoint).
30
30
  *
@@ -32,14 +32,13 @@ export interface SorterOptions {
32
32
  * @returns A synchronous `SortFn` for use with `transform()`.
33
33
  */
34
34
  export async function createTailwindSortFn(opts: SorterOptions): Promise<SortFn> {
35
- // Dynamic import so the core package works without the dependency
36
- // installed (e.g., when only running tests with a mock sorter).
37
- const { createSorter } = await import('prettier-plugin-tailwindcss/sorter');
35
+ // Dynamic import so the core package works without the dependency installed.
36
+ const { createSorter } = await import('prettier-plugin-tailwindcss/sorter');
38
37
 
39
- const sorter = await createSorter({
40
- base: opts.base ?? process.cwd(),
41
- stylesheetPath: opts.stylesheet,
42
- });
38
+ const sorter = await createSorter({
39
+ base: opts.base ?? process.cwd(),
40
+ stylesheetPath: opts.stylesheet,
41
+ });
43
42
 
44
- return (classes: string[]) => sorter.sortClassLists([classes])[0];
43
+ return (classes: string[]) => sorter.sortClassLists([classes])[0];
45
44
  }
package/src/transform.ts CHANGED
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { findIslands, type Island, type IslandOptions } from './islands.ts';
21
21
  import { maskIslands, findClassAttributes, type HtmlScanOptions } from './html.ts';
22
+ import { findSortablePhpStrings } from './php-strings.ts';
22
23
 
23
24
  /**
24
25
  * Sorting strategy injected by the caller. Receives the class tokens of a single static run;
@@ -27,9 +28,14 @@ import { maskIslands, findClassAttributes, type HtmlScanOptions } from './html.t
27
28
  export type SortFn = (classes: string[]) => string[];
28
29
 
29
30
  /**
30
- * Combined options for both lexer passes.
31
+ * Combined options for all lexer passes.
31
32
  */
32
- export interface TransformOptions extends IslandOptions, HtmlScanOptions {}
33
+ export interface TransformOptions extends IslandOptions, HtmlScanOptions {
34
+ /**
35
+ * Sort classes in PHP string-literal values too. Off by default; see `php-strings.ts` for eligibility.
36
+ */
37
+ sortPhpStrings?: boolean;
38
+ }
33
39
 
34
40
  /**
35
41
  * Rewrite all class attribute values in the template source with sorted classes.
@@ -45,90 +51,104 @@ export interface TransformOptions extends IslandOptions, HtmlScanOptions {}
45
51
  * // '<div class="mt-4 z-10 <?= $x ?> a b">'
46
52
  */
47
53
  export function transform(src: string, sortFn: SortFn, opts: TransformOptions = {}): string {
48
- const islands = findIslands(src, opts);
49
- const masked = maskIslands(src, islands);
50
- const attrs = findClassAttributes(masked, opts);
51
-
52
- // Apply replacements back-to-front so offsets stay valid.
53
- let out = src;
54
- for (let a = attrs.length - 1; a >= 0; a--) {
55
- const { valueStart, valueEnd } = attrs[a];
56
- const original = src.slice(valueStart, valueEnd);
57
- const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
58
- const rewritten = rewriteValue(original, valueStart, inner, sortFn);
59
- if (rewritten !== original) {
60
- out = out.slice(0, valueStart) + rewritten + out.slice(valueEnd);
54
+ const islands = findIslands(src, opts);
55
+ const masked = maskIslands(src, islands);
56
+ const attrs = findClassAttributes(masked, opts);
57
+
58
+ // Collect every edit as a {start, end, text} range, then apply them back-to-front so offsets stay valid.
59
+ // HTML class-attribute values live outside islands; PHP string values live inside them, so the two sets of
60
+ // ranges never overlap.
61
+ const edits: { start: number; end: number; text: string }[] = [];
62
+
63
+ for (const { valueStart, valueEnd } of attrs) {
64
+ const original = src.slice(valueStart, valueEnd);
65
+ const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
66
+ const rewritten = rewriteValue(original, valueStart, inner, sortFn);
67
+ if (rewritten !== original) edits.push({ start: valueStart, end: valueEnd, text: rewritten });
68
+ }
69
+
70
+ if (opts.sortPhpStrings) {
71
+ for (const { start, end } of findSortablePhpStrings(src, islands)) {
72
+ const original = src.slice(start, end);
73
+ const tokens = original.split(/\s+/).filter(Boolean);
74
+ if (tokens.length < 2) continue; // nothing to reorder; leave byte-identical
75
+ const rewritten = sortFn(tokens).join(' ');
76
+ if (rewritten !== original) edits.push({ start, end, text: rewritten });
77
+ }
61
78
  }
62
- }
63
- return out;
79
+
80
+ edits.sort((a, b) => b.start - a.start);
81
+ let out = src;
82
+ for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);
83
+ return out;
64
84
  }
65
85
 
66
86
  interface Part {
67
- type: 'static' | 'island';
68
- text: string;
87
+ type: 'static' | 'island';
88
+ text: string;
69
89
  }
70
90
 
71
91
  function rewriteValue(value: string, base: number, islands: Island[], sortFn: SortFn): string {
72
- // Build alternating static/island parts.
73
- const parts: Part[] = [];
74
- let pos = 0;
75
- for (const isl of islands) {
76
- const s = isl.start - base;
77
- const e = isl.end - base;
78
- parts.push({ type: 'static', text: value.slice(pos, s) });
79
- parts.push({ type: 'island', text: value.slice(s, e) });
80
- pos = e;
81
- }
82
- parts.push({ type: 'static', text: value.slice(pos) });
83
-
84
- let out = '';
85
- for (let p = 0; p < parts.length; p++) {
86
- const part = parts[p];
87
- if (part.type === 'island') {
88
- out += part.text;
89
- continue;
92
+ // Build alternating static/island parts.
93
+ const parts: Part[] = [];
94
+ let pos = 0;
95
+ for (const isl of islands) {
96
+ const s = isl.start - base;
97
+ const e = isl.end - base;
98
+ parts.push({ type: 'static', text: value.slice(pos, s) });
99
+ parts.push({ type: 'island', text: value.slice(s, e) });
100
+ pos = e;
90
101
  }
91
-
92
- const prevIsIsland = p > 0;
93
- const nextIsIsland = p < parts.length - 1;
94
- const t = part.text;
95
-
96
- const hasLeadingWs = /^\s/.test(t);
97
- const hasTrailingWs = /\s$/.test(t);
98
- const tokens = t.split(/\s+/).filter(Boolean);
99
-
100
- // Whitespace-only run between islands → preserve a single space.
101
- if (tokens.length === 0) {
102
- if (t.length > 0 && prevIsIsland && nextIsIsland) out += ' ';
103
- continue;
104
- }
105
-
106
- const pinStart = prevIsIsland && !hasLeadingWs;
107
- const pinEnd = nextIsIsland && !hasTrailingWs;
108
-
109
- let head: string[] = [];
110
- let tail: string[] = [];
111
- let middle: string[];
112
-
113
- if (pinStart && pinEnd && tokens.length === 1) {
114
- // Single fragment glued to islands on both sides.
115
- middle = [];
116
- head = [tokens[0]];
117
- } else {
118
- const from = pinStart ? 1 : 0;
119
- const to = pinEnd ? tokens.length - 1 : tokens.length;
120
- if (pinStart) head = [tokens[0]];
121
- if (pinEnd) tail = [tokens[tokens.length - 1]];
122
- middle = tokens.slice(from, to);
102
+ parts.push({ type: 'static', text: value.slice(pos) });
103
+
104
+ let out = '';
105
+ for (let p = 0; p < parts.length; p++) {
106
+ const part = parts[p];
107
+ if (part.type === 'island') {
108
+ out += part.text;
109
+ continue;
110
+ }
111
+
112
+ const prevIsIsland = p > 0;
113
+ const nextIsIsland = p < parts.length - 1;
114
+ const t = part.text;
115
+
116
+ const hasLeadingWs = /^\s/.test(t);
117
+ const hasTrailingWs = /\s$/.test(t);
118
+ const tokens = t.split(/\s+/).filter(Boolean);
119
+
120
+ // Whitespace-only run between islands → preserve a single space.
121
+ if (tokens.length === 0) {
122
+ if (t.length > 0 && prevIsIsland && nextIsIsland) out += ' ';
123
+ continue;
124
+ }
125
+
126
+ const pinStart = prevIsIsland && !hasLeadingWs;
127
+ const pinEnd = nextIsIsland && !hasTrailingWs;
128
+
129
+ let head: string[] = [];
130
+ let tail: string[] = [];
131
+ let middle: string[];
132
+
133
+ if (pinStart && pinEnd && tokens.length === 1) {
134
+ // Single fragment glued to islands on both sides.
135
+ middle = [];
136
+ head = [tokens[0]];
137
+ } else {
138
+ const from = pinStart ? 1 : 0;
139
+ const to = pinEnd ? tokens.length - 1 : tokens.length;
140
+ if (pinStart) head = [tokens[0]];
141
+ if (pinEnd) tail = [tokens[tokens.length - 1]];
142
+ middle = tokens.slice(from, to);
143
+ }
144
+
145
+ const sorted = middle.length > 1 ? sortFn(middle) : middle;
146
+ const joined = [...head, ...sorted, ...tail].join(' ');
147
+
148
+ const prefix = prevIsIsland && hasLeadingWs ? ' ' : '';
149
+ const suffix = nextIsIsland && hasTrailingWs ? ' ' : '';
150
+ out += prefix + joined + suffix;
123
151
  }
124
152
 
125
- const sorted = middle.length > 1 ? sortFn(middle) : middle;
126
- const joined = [...head, ...sorted, ...tail].join(' ');
127
-
128
- const prefix = prevIsIsland && hasLeadingWs ? ' ' : '';
129
- const suffix = nextIsIsland && hasTrailingWs ? ' ' : '';
130
- out += prefix + joined + suffix;
131
- }
132
-
133
- return out;
153
+ return out;
134
154
  }