@runtimestudio/tailwind-sort-php 0.2.1 → 0.4.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/LICENSE +1 -1
- package/README.md +140 -23
- package/dist/cli.d.ts +4 -13
- package/dist/cli.js +84 -41
- package/dist/html.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +8 -2
- package/dist/islands.js +7 -94
- package/dist/php-lexer.d.ts +29 -0
- package/dist/php-lexer.js +96 -0
- package/dist/php-strings.d.ts +47 -0
- package/dist/php-strings.js +156 -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 +48 -11
- package/package.json +55 -55
- package/src/cli.ts +168 -118
- package/src/html.ts +119 -118
- package/src/index.ts +1 -0
- package/src/init.ts +97 -89
- package/src/islands.ts +102 -185
- package/src/php-lexer.ts +93 -0
- package/src/php-strings.ts +189 -0
- package/src/sorter.ts +17 -18
- package/src/transform.ts +122 -77
package/dist/islands.js
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @see html.ts - second pass; consumes islands via `maskIslands()`.
|
|
11
11
|
*/
|
|
12
|
-
|
|
13
|
-
const isIdent = (c) => /[A-Za-z0-9_\u0080-\uffff]/.test(c);
|
|
12
|
+
import { isIdent, scanHeredoc, scanLineComment, scanQuoted } from "./php-lexer.js";
|
|
14
13
|
/**
|
|
15
14
|
* Find every PHP island in a mixed PHP/HTML template source.
|
|
16
15
|
*
|
|
@@ -69,23 +68,23 @@ function scanPhpBody(src, i) {
|
|
|
69
68
|
return i + 2;
|
|
70
69
|
// Single-quoted string.
|
|
71
70
|
if (c === "'") {
|
|
72
|
-
i = scanQuoted(src, i + 1, "'");
|
|
71
|
+
i = Math.min(scanQuoted(src, i + 1, "'", len) + 1, len);
|
|
73
72
|
continue;
|
|
74
73
|
}
|
|
75
74
|
// Double-quoted string.
|
|
76
75
|
// Limitation: nested double quotes in complex `{$a["k"]}` interpolation can desync the lexer; see README.
|
|
77
76
|
if (c === '"') {
|
|
78
|
-
i = scanQuoted(src, i + 1, '"');
|
|
77
|
+
i = Math.min(scanQuoted(src, i + 1, '"', len) + 1, len);
|
|
79
78
|
continue;
|
|
80
79
|
}
|
|
81
80
|
// Backtick (shell exec) string — same escaping rules.
|
|
82
81
|
if (c === '`') {
|
|
83
|
-
i = scanQuoted(src, i + 1, '`');
|
|
82
|
+
i = Math.min(scanQuoted(src, i + 1, '`', len) + 1, len);
|
|
84
83
|
continue;
|
|
85
84
|
}
|
|
86
85
|
// Comments.
|
|
87
86
|
if (c === '/' && src[i + 1] === '/') {
|
|
88
|
-
i = scanLineComment(src, i + 2);
|
|
87
|
+
i = scanLineComment(src, i + 2, len);
|
|
89
88
|
if (src.startsWith('?>', i))
|
|
90
89
|
return i + 2;
|
|
91
90
|
continue;
|
|
@@ -96,7 +95,7 @@ function scanPhpBody(src, i) {
|
|
|
96
95
|
i += 2;
|
|
97
96
|
continue;
|
|
98
97
|
}
|
|
99
|
-
i = scanLineComment(src, i + 1);
|
|
98
|
+
i = scanLineComment(src, i + 1, len);
|
|
100
99
|
if (src.startsWith('?>', i))
|
|
101
100
|
return i + 2;
|
|
102
101
|
continue;
|
|
@@ -108,7 +107,7 @@ function scanPhpBody(src, i) {
|
|
|
108
107
|
}
|
|
109
108
|
// Heredoc / nowdoc.
|
|
110
109
|
if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
|
|
111
|
-
const here = scanHeredoc(src, i + 3);
|
|
110
|
+
const here = scanHeredoc(src, i + 3, len);
|
|
112
111
|
if (here !== -1) {
|
|
113
112
|
i = here;
|
|
114
113
|
continue;
|
|
@@ -118,89 +117,3 @@ function scanPhpBody(src, i) {
|
|
|
118
117
|
}
|
|
119
118
|
return len; // file ends while still in PHP mode
|
|
120
119
|
}
|
|
121
|
-
/**
|
|
122
|
-
* Scan a quoted string body; `i` is just past the open quote.
|
|
123
|
-
* Returns offset just past the closing quote (or EOF).
|
|
124
|
-
*/
|
|
125
|
-
function scanQuoted(src, i, quote) {
|
|
126
|
-
const len = src.length;
|
|
127
|
-
while (i < len) {
|
|
128
|
-
const c = src[i];
|
|
129
|
-
if (c === '\\') {
|
|
130
|
-
i += 2;
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
if (c === quote)
|
|
134
|
-
return i + 1;
|
|
135
|
-
i++;
|
|
136
|
-
}
|
|
137
|
-
return len;
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Scan a `//` or `#` line comment body, which ends at a newline or at `?>`.
|
|
141
|
-
* Returns the offset past a consumed newline, or the offset of an unconsumed `?>`
|
|
142
|
-
* (left for the caller, since `?>` closes both the comment and the island).
|
|
143
|
-
*/
|
|
144
|
-
function scanLineComment(src, i) {
|
|
145
|
-
const len = src.length;
|
|
146
|
-
while (i < len) {
|
|
147
|
-
if (src[i] === '\n')
|
|
148
|
-
return i + 1;
|
|
149
|
-
if (src[i] === '?' && src[i + 1] === '>')
|
|
150
|
-
return i;
|
|
151
|
-
i++;
|
|
152
|
-
}
|
|
153
|
-
return len;
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* Scan a heredoc/nowdoc; `i` is just past `<<<`.
|
|
157
|
-
* Returns the offset past the closing identifier line, or -1 if `<<<` isn't followed by a valid heredoc identifier.
|
|
158
|
-
*/
|
|
159
|
-
function scanHeredoc(src, i) {
|
|
160
|
-
const len = src.length;
|
|
161
|
-
// Optional whitespace (PHP allows spaces/tabs after `<<<`).
|
|
162
|
-
while (i < len && (src[i] === ' ' || src[i] === '\t'))
|
|
163
|
-
i++;
|
|
164
|
-
// Optional quote around the identifier.
|
|
165
|
-
let quote = '';
|
|
166
|
-
if (src[i] === "'" || src[i] === '"') {
|
|
167
|
-
quote = src[i];
|
|
168
|
-
i++;
|
|
169
|
-
}
|
|
170
|
-
if (i >= len || !isIdentStart(src[i]))
|
|
171
|
-
return -1;
|
|
172
|
-
const idStart = i;
|
|
173
|
-
while (i < len && isIdent(src[i]))
|
|
174
|
-
i++;
|
|
175
|
-
const id = src.slice(idStart, i);
|
|
176
|
-
if (quote) {
|
|
177
|
-
if (src[i] !== quote)
|
|
178
|
-
return -1;
|
|
179
|
-
i++;
|
|
180
|
-
}
|
|
181
|
-
// After the identifier, the line must end immediately — PHP disallows trailing whitespace,
|
|
182
|
-
// but we tolerate `\r` so `\r\n` endings still work.
|
|
183
|
-
while (i < len && src[i] === '\r')
|
|
184
|
-
i++;
|
|
185
|
-
if (src[i] !== '\n')
|
|
186
|
-
return -1;
|
|
187
|
-
i++;
|
|
188
|
-
// Find a line that starts with optional indentation,
|
|
189
|
-
// then the identifier followed by a non-identifier character
|
|
190
|
-
// (PHP 7.3 flexible syntax).
|
|
191
|
-
while (i < len) {
|
|
192
|
-
let j = i;
|
|
193
|
-
while (j < len && (src[j] === ' ' || src[j] === '\t'))
|
|
194
|
-
j++;
|
|
195
|
-
if (src.startsWith(id, j)) {
|
|
196
|
-
const k = j + id.length;
|
|
197
|
-
if (k >= len || !isIdent(src[k]))
|
|
198
|
-
return k;
|
|
199
|
-
}
|
|
200
|
-
const nl = src.indexOf('\n', i);
|
|
201
|
-
if (nl === -1)
|
|
202
|
-
return len;
|
|
203
|
-
i = nl + 1;
|
|
204
|
-
}
|
|
205
|
-
return len;
|
|
206
|
-
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared low-level PHP lexing helpers.
|
|
3
|
+
*
|
|
4
|
+
* Used by both passes that read PHP code — island detection (`islands.ts`) and string harvesting (`php-strings.ts`)
|
|
5
|
+
* — so the two can never drift apart on string/comment/heredoc boundary rules.
|
|
6
|
+
*
|
|
7
|
+
* All scanners operate on the `[i, end)` window of the source and return offsets within it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const isIdentStart: (c: string) => boolean;
|
|
10
|
+
export declare const isIdent: (c: string) => boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Scan a quoted string body; `i` is just past the open quote.
|
|
13
|
+
*
|
|
14
|
+
* @returns The offset of the closing quote, or `end` when unterminated.
|
|
15
|
+
*/
|
|
16
|
+
export declare function scanQuoted(src: string, i: number, quote: string, end: number): number;
|
|
17
|
+
/**
|
|
18
|
+
* Scan a `//` or `#` line comment body, which ends at a newline or at `?>`.
|
|
19
|
+
*
|
|
20
|
+
* @returns The offset past a consumed newline, or the offset of an unconsumed `?>`
|
|
21
|
+
* (left for the caller, since `?>` closes both the comment and the island).
|
|
22
|
+
*/
|
|
23
|
+
export declare function scanLineComment(src: string, i: number, end: number): number;
|
|
24
|
+
/**
|
|
25
|
+
* Scan a heredoc/nowdoc; `i` is just past `<<<`.
|
|
26
|
+
*
|
|
27
|
+
* @returns The offset past the closing identifier, or -1 if `<<<` isn't followed by a valid heredoc identifier.
|
|
28
|
+
*/
|
|
29
|
+
export declare function scanHeredoc(src: string, i: number, end: number): number;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared low-level PHP lexing helpers.
|
|
3
|
+
*
|
|
4
|
+
* Used by both passes that read PHP code — island detection (`islands.ts`) and string harvesting (`php-strings.ts`)
|
|
5
|
+
* — so the two can never drift apart on string/comment/heredoc boundary rules.
|
|
6
|
+
*
|
|
7
|
+
* All scanners operate on the `[i, end)` window of the source and return offsets within it.
|
|
8
|
+
*/
|
|
9
|
+
export const isIdentStart = (c) => /[A-Za-z_\u0080-\uffff]/.test(c);
|
|
10
|
+
export const isIdent = (c) => /[A-Za-z0-9_\u0080-\uffff]/.test(c);
|
|
11
|
+
/**
|
|
12
|
+
* Scan a quoted string body; `i` is just past the open quote.
|
|
13
|
+
*
|
|
14
|
+
* @returns The offset of the closing quote, or `end` when unterminated.
|
|
15
|
+
*/
|
|
16
|
+
export function scanQuoted(src, i, quote, end) {
|
|
17
|
+
while (i < end) {
|
|
18
|
+
const c = src[i];
|
|
19
|
+
if (c === '\\') {
|
|
20
|
+
i += 2;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (c === quote)
|
|
24
|
+
return i;
|
|
25
|
+
i++;
|
|
26
|
+
}
|
|
27
|
+
return end;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Scan a `//` or `#` line comment body, which ends at a newline or at `?>`.
|
|
31
|
+
*
|
|
32
|
+
* @returns The offset past a consumed newline, or the offset of an unconsumed `?>`
|
|
33
|
+
* (left for the caller, since `?>` closes both the comment and the island).
|
|
34
|
+
*/
|
|
35
|
+
export function scanLineComment(src, i, end) {
|
|
36
|
+
while (i < end) {
|
|
37
|
+
if (src[i] === '\n')
|
|
38
|
+
return i + 1;
|
|
39
|
+
if (src[i] === '?' && src[i + 1] === '>')
|
|
40
|
+
return i;
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
return end;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Scan a heredoc/nowdoc; `i` is just past `<<<`.
|
|
47
|
+
*
|
|
48
|
+
* @returns The offset past the closing identifier, or -1 if `<<<` isn't followed by a valid heredoc identifier.
|
|
49
|
+
*/
|
|
50
|
+
export function scanHeredoc(src, i, end) {
|
|
51
|
+
// Optional whitespace (PHP allows spaces/tabs after `<<<`).
|
|
52
|
+
while (i < end && (src[i] === ' ' || src[i] === '\t'))
|
|
53
|
+
i++;
|
|
54
|
+
// Optional quote around the identifier.
|
|
55
|
+
let quote = '';
|
|
56
|
+
if (src[i] === "'" || src[i] === '"') {
|
|
57
|
+
quote = src[i];
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
if (i >= end || !isIdentStart(src[i]))
|
|
61
|
+
return -1;
|
|
62
|
+
const idStart = i;
|
|
63
|
+
while (i < end && isIdent(src[i]))
|
|
64
|
+
i++;
|
|
65
|
+
const id = src.slice(idStart, i);
|
|
66
|
+
if (quote) {
|
|
67
|
+
if (src[i] !== quote)
|
|
68
|
+
return -1;
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
// After the identifier, the line must end immediately — PHP disallows trailing whitespace,
|
|
72
|
+
// but we tolerate `\r` so `\r\n` endings still work.
|
|
73
|
+
while (i < end && src[i] === '\r')
|
|
74
|
+
i++;
|
|
75
|
+
if (src[i] !== '\n')
|
|
76
|
+
return -1;
|
|
77
|
+
i++;
|
|
78
|
+
// Find a line that starts with optional indentation,
|
|
79
|
+
// then the identifier followed by a non-identifier character
|
|
80
|
+
// (PHP 7.3 flexible syntax).
|
|
81
|
+
while (i < end) {
|
|
82
|
+
let j = i;
|
|
83
|
+
while (j < end && (src[j] === ' ' || src[j] === '\t'))
|
|
84
|
+
j++;
|
|
85
|
+
if (src.startsWith(id, j)) {
|
|
86
|
+
const k = j + id.length;
|
|
87
|
+
if (k >= end || !isIdent(src[k]))
|
|
88
|
+
return k;
|
|
89
|
+
}
|
|
90
|
+
const nl = src.indexOf('\n', i);
|
|
91
|
+
if (nl === -1 || nl >= end)
|
|
92
|
+
return end;
|
|
93
|
+
i = nl + 1;
|
|
94
|
+
}
|
|
95
|
+
return end;
|
|
96
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
import type { Island } from './islands.ts';
|
|
23
|
+
/**
|
|
24
|
+
* Inner byte range of a sortable string literal — the span *between* the quotes, in original-source offsets.
|
|
25
|
+
*/
|
|
26
|
+
export interface PhpStringRange {
|
|
27
|
+
/**
|
|
28
|
+
* Offset of the first character inside the quotes.
|
|
29
|
+
*/
|
|
30
|
+
start: number;
|
|
31
|
+
/**
|
|
32
|
+
* Offset just past the last character inside the quotes (exclusive).
|
|
33
|
+
*/
|
|
34
|
+
end: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Find every sortable string-literal value within the given PHP islands.
|
|
38
|
+
*
|
|
39
|
+
* @param src Original template source.
|
|
40
|
+
* @param islands Island ranges from `findIslands()`.
|
|
41
|
+
* @returns Inner ranges of eligible string values, in document order.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const islands = findIslands(`<?php $x = 'z a'; ?>`);
|
|
45
|
+
* findSortablePhpStrings(`<?php $x = 'z a'; ?>`, islands); // [{ start: 11, end: 14 }]
|
|
46
|
+
*/
|
|
47
|
+
export declare function findSortablePhpStrings(src: string, islands: Island[]): PhpStringRange[];
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
import { isIdent, isIdentStart, scanHeredoc, scanLineComment, scanQuoted } from "./php-lexer.js";
|
|
23
|
+
/**
|
|
24
|
+
* Find every sortable string-literal value within the given PHP islands.
|
|
25
|
+
*
|
|
26
|
+
* @param src Original template source.
|
|
27
|
+
* @param islands Island ranges from `findIslands()`.
|
|
28
|
+
* @returns Inner ranges of eligible string values, in document order.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const islands = findIslands(`<?php $x = 'z a'; ?>`);
|
|
32
|
+
* findSortablePhpStrings(`<?php $x = 'z a'; ?>`, islands); // [{ start: 11, end: 14 }]
|
|
33
|
+
*/
|
|
34
|
+
export function findSortablePhpStrings(src, islands) {
|
|
35
|
+
const ranges = [];
|
|
36
|
+
for (const isl of islands) {
|
|
37
|
+
const tokens = tokenizeIsland(src, isl.start, isl.end);
|
|
38
|
+
for (let k = 0; k < tokens.length; k++) {
|
|
39
|
+
const token = tokens[k];
|
|
40
|
+
if (token.kind !== 'string' || token.skip)
|
|
41
|
+
continue;
|
|
42
|
+
const next = tokens[k + 1];
|
|
43
|
+
const prev = tokens[k - 1];
|
|
44
|
+
// Array key (`'left' => ...`) — never sorted.
|
|
45
|
+
if (next && next.kind === 'arrow')
|
|
46
|
+
continue;
|
|
47
|
+
// Part of a concatenation expression (`'btn-' . $v` / `$v . 'suffix'`).
|
|
48
|
+
if ((next && next.kind === 'dot') || (prev && prev.kind === 'dot'))
|
|
49
|
+
continue;
|
|
50
|
+
ranges.push({ start: token.start, end: token.end });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return ranges;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Tokenize one PHP island into the minimal token stream needed for value classification: string literals
|
|
57
|
+
* (with their inner range and a skip flag), the `=>` arrow, the `.` concatenation operator, and a coalesced `other`
|
|
58
|
+
* marker for everything else. Whitespace and comments are dropped so adjacency is judged across them.
|
|
59
|
+
*/
|
|
60
|
+
function tokenizeIsland(src, start, end) {
|
|
61
|
+
const tokens = [];
|
|
62
|
+
const pushOther = () => {
|
|
63
|
+
if (tokens.length === 0 || tokens[tokens.length - 1].kind !== 'other')
|
|
64
|
+
tokens.push({ kind: 'other' });
|
|
65
|
+
};
|
|
66
|
+
let i = start;
|
|
67
|
+
while (i < end) {
|
|
68
|
+
const c = src[i];
|
|
69
|
+
// Whitespace.
|
|
70
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v') {
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// Line comments (`//`, `#`) — but `#[` opens a PHP 8 attribute, which is code.
|
|
75
|
+
if (c === '/' && src[i + 1] === '/') {
|
|
76
|
+
i = scanLineComment(src, i + 2, end);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (c === '#' && src[i + 1] !== '[') {
|
|
80
|
+
i = scanLineComment(src, i + 1, end);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// Block comment.
|
|
84
|
+
if (c === '/' && src[i + 1] === '*') {
|
|
85
|
+
const close = src.indexOf('*/', i + 2);
|
|
86
|
+
i = close === -1 || close + 2 > end ? end : close + 2;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Heredoc / nowdoc — never harvested; skip the whole construct.
|
|
90
|
+
if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
|
|
91
|
+
const here = scanHeredoc(src, i + 3, end);
|
|
92
|
+
if (here !== -1) {
|
|
93
|
+
i = here;
|
|
94
|
+
pushOther();
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Quoted strings.
|
|
99
|
+
if (c === "'") {
|
|
100
|
+
const close = scanQuoted(src, i + 1, "'", end);
|
|
101
|
+
const inner = src.slice(i + 1, close);
|
|
102
|
+
tokens.push({ kind: 'string', start: i + 1, end: close, skip: inner.includes('\\') });
|
|
103
|
+
i = close + 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (c === '"') {
|
|
107
|
+
const close = scanQuoted(src, i + 1, '"', end);
|
|
108
|
+
const inner = src.slice(i + 1, close);
|
|
109
|
+
// Skip interpolation (any unescaped `$`) and escapes.
|
|
110
|
+
tokens.push({ kind: 'string', start: i + 1, end: close, skip: hasInterpolationOrEscape(inner) });
|
|
111
|
+
i = close + 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (c === '`') {
|
|
115
|
+
// Shell-exec string — never a class list.
|
|
116
|
+
i = scanQuoted(src, i + 1, '`', end) + 1;
|
|
117
|
+
pushOther();
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Operators that matter for classification.
|
|
121
|
+
if (c === '=' && src[i + 1] === '>') {
|
|
122
|
+
tokens.push({ kind: 'arrow' });
|
|
123
|
+
i += 2;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (c === '.') {
|
|
127
|
+
tokens.push({ kind: 'dot' });
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Everything else (identifiers, punctuation, the `<?php`/`?>` tags themselves).
|
|
132
|
+
pushOther();
|
|
133
|
+
if (isIdentStart(c)) {
|
|
134
|
+
i++;
|
|
135
|
+
while (i < end && isIdent(src[i]))
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
i++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return tokens;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* True if a double-quoted body contains interpolation (an unescaped `$`) or any escape sequence.
|
|
146
|
+
*/
|
|
147
|
+
function hasInterpolationOrEscape(body) {
|
|
148
|
+
for (let i = 0; i < body.length; i++) {
|
|
149
|
+
const c = body[i];
|
|
150
|
+
if (c === '\\')
|
|
151
|
+
return true;
|
|
152
|
+
if (c === '$')
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
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
|
|
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,
|
|
24
|
-
* the project's Tailwind v4 stylesheet so custom
|
|
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,
|
|
10
|
-
* the project's Tailwind v4 stylesheet so custom
|
|
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(),
|
package/dist/transform.d.ts
CHANGED
|
@@ -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
|
|
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,28 +36,64 @@ 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
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
// Computed before the attribute pass: an edit inside a class-attribute island must be folded into that
|
|
40
|
+
// attribute's rewrite, or the enclosing rewrite would overwrite it and a second pass would be needed.
|
|
41
|
+
const phpEdits = [];
|
|
42
|
+
if (opts.sortPhpStrings) {
|
|
43
|
+
for (const { start, end } of findSortablePhpStrings(src, islands)) {
|
|
44
|
+
const original = src.slice(start, end);
|
|
45
|
+
const tokens = original.split(/\s+/).filter(Boolean);
|
|
46
|
+
if (tokens.length < 2)
|
|
47
|
+
continue; // nothing to reorder; leave byte-identical
|
|
48
|
+
const text = sortFn(tokens).join(' ');
|
|
49
|
+
if (text !== original)
|
|
50
|
+
phpEdits.push({ start, end, text });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Collect every edit as a {start, end, text} range, then apply them back-to-front so offsets stay valid.
|
|
54
|
+
const edits = [];
|
|
55
|
+
const absorbed = new Set();
|
|
56
|
+
for (const { valueStart, valueEnd } of attrs) {
|
|
42
57
|
const original = src.slice(valueStart, valueEnd);
|
|
43
58
|
const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
const innerEdits = phpEdits.filter((e) => e.start >= valueStart && e.end <= valueEnd);
|
|
60
|
+
const rewritten = rewriteValue(original, valueStart, inner, sortFn, innerEdits);
|
|
61
|
+
if (rewritten !== original)
|
|
62
|
+
edits.push({ start: valueStart, end: valueEnd, text: rewritten });
|
|
63
|
+
for (const e of innerEdits)
|
|
64
|
+
absorbed.add(e);
|
|
48
65
|
}
|
|
66
|
+
for (const e of phpEdits)
|
|
67
|
+
if (!absorbed.has(e))
|
|
68
|
+
edits.push(e);
|
|
69
|
+
edits.sort((a, b) => b.start - a.start);
|
|
70
|
+
let out = src;
|
|
71
|
+
for (const e of edits)
|
|
72
|
+
out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
49
73
|
return out;
|
|
50
74
|
}
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Apply the edits that fall entirely within `[offset, offset + text.length)` to `text`.
|
|
77
|
+
* Edits arrive in document order and never overlap, so a back-to-front pass keeps offsets valid.
|
|
78
|
+
*/
|
|
79
|
+
function spliceEdits(text, offset, edits) {
|
|
80
|
+
for (let k = edits.length - 1; k >= 0; k--) {
|
|
81
|
+
const e = edits[k];
|
|
82
|
+
if (e.start < offset || e.end > offset + text.length)
|
|
83
|
+
continue;
|
|
84
|
+
text = text.slice(0, e.start - offset) + e.text + text.slice(e.end - offset);
|
|
85
|
+
}
|
|
86
|
+
return text;
|
|
87
|
+
}
|
|
88
|
+
function rewriteValue(value, base, islands, sortFn, phpEdits) {
|
|
89
|
+
// Build alternating static/island parts; island text carries any PHP-string edits it contains.
|
|
53
90
|
const parts = [];
|
|
54
91
|
let pos = 0;
|
|
55
92
|
for (const isl of islands) {
|
|
56
93
|
const s = isl.start - base;
|
|
57
94
|
const e = isl.end - base;
|
|
58
95
|
parts.push({ type: 'static', text: value.slice(pos, s) });
|
|
59
|
-
parts.push({ type: 'island', text: value.slice(s, e) });
|
|
96
|
+
parts.push({ type: 'island', text: spliceEdits(value.slice(s, e), isl.start, phpEdits) });
|
|
60
97
|
pos = e;
|
|
61
98
|
}
|
|
62
99
|
parts.push({ type: 'static', text: value.slice(pos) });
|