@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/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
|
|
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,129 @@ 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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
const islands = findIslands(src, opts);
|
|
55
|
+
const masked = maskIslands(src, islands);
|
|
56
|
+
const attrs = findClassAttributes(masked, opts);
|
|
57
|
+
|
|
58
|
+
// Computed before the attribute pass: an edit inside a class-attribute island must be folded into that
|
|
59
|
+
// attribute's rewrite, or the enclosing rewrite would overwrite it and a second pass would be needed.
|
|
60
|
+
const phpEdits: Edit[] = [];
|
|
61
|
+
if (opts.sortPhpStrings) {
|
|
62
|
+
for (const { start, end } of findSortablePhpStrings(src, islands)) {
|
|
63
|
+
const original = src.slice(start, end);
|
|
64
|
+
const tokens = original.split(/\s+/).filter(Boolean);
|
|
65
|
+
if (tokens.length < 2) continue; // nothing to reorder; leave byte-identical
|
|
66
|
+
const text = sortFn(tokens).join(' ');
|
|
67
|
+
if (text !== original) phpEdits.push({ start, end, text });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Collect every edit as a {start, end, text} range, then apply them back-to-front so offsets stay valid.
|
|
72
|
+
const edits: Edit[] = [];
|
|
73
|
+
const absorbed = new Set<Edit>();
|
|
74
|
+
|
|
75
|
+
for (const { valueStart, valueEnd } of attrs) {
|
|
76
|
+
const original = src.slice(valueStart, valueEnd);
|
|
77
|
+
const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
|
|
78
|
+
const innerEdits = phpEdits.filter((e) => e.start >= valueStart && e.end <= valueEnd);
|
|
79
|
+
const rewritten = rewriteValue(original, valueStart, inner, sortFn, innerEdits);
|
|
80
|
+
if (rewritten !== original) edits.push({ start: valueStart, end: valueEnd, text: rewritten });
|
|
81
|
+
for (const e of innerEdits) absorbed.add(e);
|
|
61
82
|
}
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
|
|
84
|
+
for (const e of phpEdits) if (!absorbed.has(e)) edits.push(e);
|
|
85
|
+
|
|
86
|
+
edits.sort((a, b) => b.start - a.start);
|
|
87
|
+
let out = src;
|
|
88
|
+
for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Edit {
|
|
93
|
+
start: number;
|
|
94
|
+
end: number;
|
|
95
|
+
text: string;
|
|
64
96
|
}
|
|
65
97
|
|
|
66
98
|
interface Part {
|
|
67
|
-
|
|
68
|
-
|
|
99
|
+
type: 'static' | 'island';
|
|
100
|
+
text: string;
|
|
69
101
|
}
|
|
70
102
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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;
|
|
103
|
+
/**
|
|
104
|
+
* Apply the edits that fall entirely within `[offset, offset + text.length)` to `text`.
|
|
105
|
+
* Edits arrive in document order and never overlap, so a back-to-front pass keeps offsets valid.
|
|
106
|
+
*/
|
|
107
|
+
function spliceEdits(text: string, offset: number, edits: Edit[]): string {
|
|
108
|
+
for (let k = edits.length - 1; k >= 0; k--) {
|
|
109
|
+
const e = edits[k];
|
|
110
|
+
if (e.start < offset || e.end > offset + text.length) continue;
|
|
111
|
+
text = text.slice(0, e.start - offset) + e.text + text.slice(e.end - offset);
|
|
90
112
|
}
|
|
113
|
+
return text;
|
|
114
|
+
}
|
|
91
115
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (t.length > 0 && prevIsIsland && nextIsIsland) out += ' ';
|
|
103
|
-
continue;
|
|
116
|
+
function rewriteValue(value: string, base: number, islands: Island[], sortFn: SortFn, phpEdits: Edit[]): string {
|
|
117
|
+
// Build alternating static/island parts; island text carries any PHP-string edits it contains.
|
|
118
|
+
const parts: Part[] = [];
|
|
119
|
+
let pos = 0;
|
|
120
|
+
for (const isl of islands) {
|
|
121
|
+
const s = isl.start - base;
|
|
122
|
+
const e = isl.end - base;
|
|
123
|
+
parts.push({ type: 'static', text: value.slice(pos, s) });
|
|
124
|
+
parts.push({ type: 'island', text: spliceEdits(value.slice(s, e), isl.start, phpEdits) });
|
|
125
|
+
pos = e;
|
|
104
126
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
parts.push({ type: 'static', text: value.slice(pos) });
|
|
128
|
+
|
|
129
|
+
let out = '';
|
|
130
|
+
for (let p = 0; p < parts.length; p++) {
|
|
131
|
+
const part = parts[p];
|
|
132
|
+
if (part.type === 'island') {
|
|
133
|
+
out += part.text;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const prevIsIsland = p > 0;
|
|
138
|
+
const nextIsIsland = p < parts.length - 1;
|
|
139
|
+
const t = part.text;
|
|
140
|
+
|
|
141
|
+
const hasLeadingWs = /^\s/.test(t);
|
|
142
|
+
const hasTrailingWs = /\s$/.test(t);
|
|
143
|
+
const tokens = t.split(/\s+/).filter(Boolean);
|
|
144
|
+
|
|
145
|
+
// Whitespace-only run between islands → preserve a single space.
|
|
146
|
+
if (tokens.length === 0) {
|
|
147
|
+
if (t.length > 0 && prevIsIsland && nextIsIsland) out += ' ';
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const pinStart = prevIsIsland && !hasLeadingWs;
|
|
152
|
+
const pinEnd = nextIsIsland && !hasTrailingWs;
|
|
153
|
+
|
|
154
|
+
let head: string[] = [];
|
|
155
|
+
let tail: string[] = [];
|
|
156
|
+
let middle: string[];
|
|
157
|
+
|
|
158
|
+
if (pinStart && pinEnd && tokens.length === 1) {
|
|
159
|
+
// Single fragment glued to islands on both sides.
|
|
160
|
+
middle = [];
|
|
161
|
+
head = [tokens[0]];
|
|
162
|
+
} else {
|
|
163
|
+
const from = pinStart ? 1 : 0;
|
|
164
|
+
const to = pinEnd ? tokens.length - 1 : tokens.length;
|
|
165
|
+
if (pinStart) head = [tokens[0]];
|
|
166
|
+
if (pinEnd) tail = [tokens[tokens.length - 1]];
|
|
167
|
+
middle = tokens.slice(from, to);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const sorted = middle.length > 1 ? sortFn(middle) : middle;
|
|
171
|
+
const joined = [...head, ...sorted, ...tail].join(' ');
|
|
172
|
+
|
|
173
|
+
const prefix = prevIsIsland && hasLeadingWs ? ' ' : '';
|
|
174
|
+
const suffix = nextIsIsland && hasTrailingWs ? ' ' : '';
|
|
175
|
+
out += prefix + joined + suffix;
|
|
123
176
|
}
|
|
124
177
|
|
|
125
|
-
|
|
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;
|
|
178
|
+
return out;
|
|
134
179
|
}
|