@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/README.md +135 -23
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +34 -20
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +8 -2
- package/dist/php-strings.d.ts +47 -0
- package/dist/php-strings.js +230 -0
- package/dist/sorter.d.ts +3 -3
- package/dist/sorter.js +3 -4
- package/dist/transform.d.ts +5 -1
- package/dist/transform.js +22 -6
- package/package.json +55 -55
- package/src/cli.ts +121 -104
- package/src/html.ts +118 -118
- package/src/index.ts +1 -0
- package/src/init.ts +97 -89
- package/src/islands.ts +155 -155
- package/src/php-strings.ts +260 -0
- package/src/sorter.ts +17 -18
- package/src/transform.ts +98 -78
package/src/islands.ts
CHANGED
|
@@ -14,25 +14,25 @@
|
|
|
14
14
|
* A contiguous region of PHP code within a mixed template source.
|
|
15
15
|
*/
|
|
16
16
|
export interface Island {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Inclusive start offset of `<?`.
|
|
19
|
+
*/
|
|
20
|
+
start: number;
|
|
21
|
+
/**
|
|
22
|
+
* Exclusive end offset (just past `?>`, or EOF).
|
|
23
|
+
*/
|
|
24
|
+
end: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Options controlling PHP open-tag recognition.
|
|
29
29
|
*/
|
|
30
30
|
export interface IslandOptions {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Treat bare `<?` as a PHP open tag (short_open_tag).
|
|
33
|
+
* Default true, with a guard so `<?xml` is never treated as PHP.
|
|
34
|
+
*/
|
|
35
|
+
shortOpenTags?: boolean;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const isIdentStart = (c: string) => /[A-Za-z_\u0080-\uffff]/.test(c);
|
|
@@ -53,35 +53,35 @@ const isIdent = (c: string) => /[A-Za-z0-9_\u0080-\uffff]/.test(c);
|
|
|
53
53
|
* // [{ start: 3, end: 12 }]
|
|
54
54
|
*/
|
|
55
55
|
export function findIslands(src: string, opts: IslandOptions = {}): Island[] {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
56
|
+
const shortTags = opts.shortOpenTags !== false;
|
|
57
|
+
const islands: Island[] = [];
|
|
58
|
+
const len = src.length;
|
|
59
|
+
let i = 0;
|
|
60
|
+
|
|
61
|
+
while (i < len) {
|
|
62
|
+
const open = src.indexOf('<?', i);
|
|
63
|
+
if (open === -1) break;
|
|
64
|
+
|
|
65
|
+
// Classify the open tag.
|
|
66
|
+
const after = src.slice(open + 2, open + 6).toLowerCase();
|
|
67
|
+
let bodyStart: number;
|
|
68
|
+
if (after.startsWith('php') && (open + 5 >= len || !isIdent(src[open + 5]))) {
|
|
69
|
+
bodyStart = open + 5;
|
|
70
|
+
} else if (src[open + 2] === '=') {
|
|
71
|
+
bodyStart = open + 3;
|
|
72
|
+
} else if (shortTags && !after.startsWith('xml')) {
|
|
73
|
+
bodyStart = open + 2;
|
|
74
|
+
} else {
|
|
75
|
+
i = open + 2; // not a PHP tag (e.g. `<?xml`) — keep scanning
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const end = scanPhpBody(src, bodyStart);
|
|
80
|
+
islands.push({ start: open, end });
|
|
81
|
+
i = end;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
|
|
80
|
-
islands.push({ start: open, end });
|
|
81
|
-
i = end;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return islands;
|
|
84
|
+
return islands;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
@@ -89,68 +89,68 @@ export function findIslands(src: string, opts: IslandOptions = {}): Island[] {
|
|
|
89
89
|
* or `src.length` if the file ends in PHP mode.
|
|
90
90
|
*/
|
|
91
91
|
function scanPhpBody(src: string, i: number): number {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
92
|
+
const len = src.length;
|
|
93
|
+
|
|
94
|
+
while (i < len) {
|
|
95
|
+
const c = src[i];
|
|
96
|
+
|
|
97
|
+
// Possible close tag.
|
|
98
|
+
if (c === '?' && src[i + 1] === '>') return i + 2;
|
|
99
|
+
|
|
100
|
+
// Single-quoted string.
|
|
101
|
+
if (c === "'") {
|
|
102
|
+
i = scanQuoted(src, i + 1, "'");
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Double-quoted string.
|
|
107
|
+
// Limitation: nested double quotes in complex `{$a["k"]}` interpolation can desync the lexer; see README.
|
|
108
|
+
if (c === '"') {
|
|
109
|
+
i = scanQuoted(src, i + 1, '"');
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Backtick (shell exec) string — same escaping rules.
|
|
114
|
+
if (c === '`') {
|
|
115
|
+
i = scanQuoted(src, i + 1, '`');
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Comments.
|
|
120
|
+
if (c === '/' && src[i + 1] === '/') {
|
|
121
|
+
i = scanLineComment(src, i + 2);
|
|
122
|
+
if (src.startsWith('?>', i)) return i + 2;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (c === '#') {
|
|
126
|
+
// PHP 8 attribute `#[...]` is not a comment.
|
|
127
|
+
if (src[i + 1] === '[') {
|
|
128
|
+
i += 2;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
i = scanLineComment(src, i + 1);
|
|
132
|
+
if (src.startsWith('?>', i)) return i + 2;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (c === '/' && src[i + 1] === '*') {
|
|
136
|
+
const close = src.indexOf('*/', i + 2);
|
|
137
|
+
i = close === -1 ? len : close + 2;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Heredoc / nowdoc.
|
|
142
|
+
if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
|
|
143
|
+
const here = scanHeredoc(src, i + 3);
|
|
144
|
+
if (here !== -1) {
|
|
145
|
+
i = here;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
i++;
|
|
139
151
|
}
|
|
140
152
|
|
|
141
|
-
//
|
|
142
|
-
if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
|
|
143
|
-
const here = scanHeredoc(src, i + 3);
|
|
144
|
-
if (here !== -1) {
|
|
145
|
-
i = here;
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
i++;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return len; // file ends while still in PHP mode
|
|
153
|
+
return len; // file ends while still in PHP mode
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/**
|
|
@@ -158,17 +158,17 @@ function scanPhpBody(src: string, i: number): number {
|
|
|
158
158
|
* Returns offset just past the closing quote (or EOF).
|
|
159
159
|
*/
|
|
160
160
|
function scanQuoted(src: string, i: number, quote: string): number {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
161
|
+
const len = src.length;
|
|
162
|
+
while (i < len) {
|
|
163
|
+
const c = src[i];
|
|
164
|
+
if (c === '\\') {
|
|
165
|
+
i += 2;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (c === quote) return i + 1;
|
|
169
|
+
i++;
|
|
167
170
|
}
|
|
168
|
-
|
|
169
|
-
i++;
|
|
170
|
-
}
|
|
171
|
-
return len;
|
|
171
|
+
return len;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/**
|
|
@@ -177,13 +177,13 @@ function scanQuoted(src: string, i: number, quote: string): number {
|
|
|
177
177
|
* (left for the caller, since `?>` closes both the comment and the island).
|
|
178
178
|
*/
|
|
179
179
|
function scanLineComment(src: string, i: number): number {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
180
|
+
const len = src.length;
|
|
181
|
+
while (i < len) {
|
|
182
|
+
if (src[i] === '\n') return i + 1;
|
|
183
|
+
if (src[i] === '?' && src[i + 1] === '>') return i;
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
return len;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
/**
|
|
@@ -191,46 +191,46 @@ function scanLineComment(src: string, i: number): number {
|
|
|
191
191
|
* Returns the offset past the closing identifier line, or -1 if `<<<` isn't followed by a valid heredoc identifier.
|
|
192
192
|
*/
|
|
193
193
|
function scanHeredoc(src: string, i: number): number {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
194
|
+
const len = src.length;
|
|
195
|
+
// Optional whitespace (PHP allows spaces/tabs after `<<<`).
|
|
196
|
+
while (i < len && (src[i] === ' ' || src[i] === '\t')) i++;
|
|
197
|
+
|
|
198
|
+
// Optional quote around the identifier.
|
|
199
|
+
let quote = '';
|
|
200
|
+
if (src[i] === "'" || src[i] === '"') {
|
|
201
|
+
quote = src[i];
|
|
202
|
+
i++;
|
|
203
|
+
}
|
|
204
204
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
205
|
+
if (i >= len || !isIdentStart(src[i])) return -1;
|
|
206
|
+
const idStart = i;
|
|
207
|
+
while (i < len && isIdent(src[i])) i++;
|
|
208
|
+
const id = src.slice(idStart, i);
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
if (quote) {
|
|
211
|
+
if (src[i] !== quote) return -1;
|
|
212
|
+
i++;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// After the identifier, the line must end immediately — PHP disallows trailing whitespace,
|
|
216
|
+
// but we tolerate `\r` so `\r\n` endings still work.
|
|
217
|
+
while (i < len && src[i] === '\r') i++;
|
|
218
|
+
if (src[i] !== '\n') return -1;
|
|
212
219
|
i++;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (src.startsWith(id, j)) {
|
|
228
|
-
const k = j + id.length;
|
|
229
|
-
if (k >= len || !isIdent(src[k])) return k;
|
|
220
|
+
|
|
221
|
+
// Find a line that starts with optional indentation,
|
|
222
|
+
// then the identifier followed by a non-identifier character
|
|
223
|
+
// (PHP 7.3 flexible syntax).
|
|
224
|
+
while (i < len) {
|
|
225
|
+
let j = i;
|
|
226
|
+
while (j < len && (src[j] === ' ' || src[j] === '\t')) j++;
|
|
227
|
+
if (src.startsWith(id, j)) {
|
|
228
|
+
const k = j + id.length;
|
|
229
|
+
if (k >= len || !isIdent(src[k])) return k;
|
|
230
|
+
}
|
|
231
|
+
const nl = src.indexOf('\n', i);
|
|
232
|
+
if (nl === -1) return len;
|
|
233
|
+
i = nl + 1;
|
|
230
234
|
}
|
|
231
|
-
|
|
232
|
-
if (nl === -1) return len;
|
|
233
|
-
i = nl + 1;
|
|
234
|
-
}
|
|
235
|
-
return len;
|
|
235
|
+
return len;
|
|
236
236
|
}
|
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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,
|
|
27
|
-
* the project's Tailwind v4 stylesheet so custom
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
const sorter = await createSorter({
|
|
39
|
+
base: opts.base ?? process.cwd(),
|
|
40
|
+
stylesheetPath: opts.stylesheet,
|
|
41
|
+
});
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+
return (classes: string[]) => sorter.sortClassLists([classes])[0];
|
|
45
44
|
}
|