@runtimestudio/tailwind-sort-php 0.3.0 → 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 +8 -3
- package/dist/cli.d.ts +4 -14
- package/dist/cli.js +53 -24
- package/dist/html.js +3 -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.js +4 -78
- package/dist/transform.js +38 -17
- package/package.json +3 -3
- package/src/cli.ts +54 -21
- package/src/html.ts +3 -2
- package/src/islands.ts +8 -91
- package/src/php-lexer.ts +93 -0
- package/src/php-strings.ts +4 -75
- package/src/transform.ts +41 -16
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026
|
|
3
|
+
Copyright (c) 2026 G&J Sevastos Family Trust t/a Runtime Studio
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.md
CHANGED
|
@@ -91,6 +91,8 @@ npx tailwind-sort-php init
|
|
|
91
91
|
| `--php-source <glob>` | Also sort class strings in PHP declarations in matching files (repeatable). Merged with `tailwindPhpSources`. See [Sorting classes in PHP declarations](#sorting-classes-in-php-declarations). |
|
|
92
92
|
| `--check` | Don't write; exit 1 if any file needs sorting. |
|
|
93
93
|
| `--no-short-tags` | Don't treat bare `<?` as a PHP open tag. |
|
|
94
|
+
| `-h, --help` | Show usage. |
|
|
95
|
+
| `--version` | Print the version. |
|
|
94
96
|
|
|
95
97
|
Default globs are all `.php` files under the cwd; `node_modules`, `vendor`, `dist`, and `.git` are always skipped.
|
|
96
98
|
|
|
@@ -278,6 +280,8 @@ Also handled correctly:
|
|
|
278
280
|
- `?>` inside `//` and `#` line comments (island ends — genuine PHP behavior)
|
|
279
281
|
- `#[Attributes]`, `<?PHP` case-insensitivity, `<?xml` exclusion, files ending in PHP mode
|
|
280
282
|
- PHP islands as standalone attributes: `<div <?php post_class(); ?> class="...">`
|
|
283
|
+
- With `tailwindPhpSources`, string values inside an island in a class attribute
|
|
284
|
+
(`class="p-4 <?= $on ? 'flex z-10' : '' ?>"`) sort in the same single pass
|
|
281
285
|
- `<script>`/`<style>` content, HTML comments, and `echo '<div class="...">'` strings are left alone (to sort class
|
|
282
286
|
strings declared in PHP, see [Sorting classes in PHP declarations](#sorting-classes-in-php-declarations))
|
|
283
287
|
|
|
@@ -316,10 +320,11 @@ bun test # or: node --test "test/*.test.ts"
|
|
|
316
320
|
bun run build # compile src → dist (tsc); the published artifact
|
|
317
321
|
```
|
|
318
322
|
|
|
319
|
-
|
|
323
|
+
84 tests: 63 core tests that are dependency-free (the sorter is injected, so they run against a mock `SortFn`),
|
|
320
324
|
7 integration tests that exercise the real `prettier-plugin-tailwindcss` sorter and skip automatically when the
|
|
321
|
-
Tailwind toolchain isn't installed,
|
|
322
|
-
`git` is unavailable
|
|
325
|
+
Tailwind toolchain isn't installed, 8 `init` tests that run against throwaway git repositories and skip when
|
|
326
|
+
`git` is unavailable, and 6 CLI tests covering argument validation, help/version output, and file-scanning rules
|
|
327
|
+
(the scan test also skips without the Tailwind toolchain).
|
|
323
328
|
|
|
324
329
|
## License
|
|
325
330
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* tailwind-sort-php CLI
|
|
3
|
+
* tailwind-sort-php CLI — see `USAGE` for the flag reference.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Options:
|
|
10
|
-
* --stylesheet <path> Tailwind v4 CSS entry
|
|
11
|
-
* --attr <name> Extra attribute to sort (repeatable)
|
|
12
|
-
* --php-source <glob> Also sort class strings in PHP declarations in matching files (repeatable)
|
|
13
|
-
* --check Don't write; exit 1 if any file needs sorting
|
|
14
|
-
* --no-short-tags Don't treat bare `<?` as a PHP open tag
|
|
15
|
-
*
|
|
16
|
-
* Defaults to all `.php` files under `cwd` when no globs are given.
|
|
17
|
-
* Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
|
|
5
|
+
* Option values fall back to the resolved Prettier config — the same source of truth `prettier-plugin-tailwindcss`
|
|
6
|
+
* uses; see `fromPrettierConfig()`.
|
|
7
|
+
* The `init` subcommand installs the pre-commit hook; see `init.ts`.
|
|
18
8
|
*/
|
|
19
9
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* tailwind-sort-php CLI
|
|
3
|
+
* tailwind-sort-php CLI — see `USAGE` for the flag reference.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Options:
|
|
10
|
-
* --stylesheet <path> Tailwind v4 CSS entry
|
|
11
|
-
* --attr <name> Extra attribute to sort (repeatable)
|
|
12
|
-
* --php-source <glob> Also sort class strings in PHP declarations in matching files (repeatable)
|
|
13
|
-
* --check Don't write; exit 1 if any file needs sorting
|
|
14
|
-
* --no-short-tags Don't treat bare `<?` as a PHP open tag
|
|
15
|
-
*
|
|
16
|
-
* Defaults to all `.php` files under `cwd` when no globs are given.
|
|
17
|
-
* Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
|
|
5
|
+
* Option values fall back to the resolved Prettier config — the same source of truth `prettier-plugin-tailwindcss`
|
|
6
|
+
* uses; see `fromPrettierConfig()`.
|
|
7
|
+
* The `init` subcommand installs the pre-commit hook; see `init.ts`.
|
|
18
8
|
*/
|
|
19
9
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
20
10
|
import { transform } from "./transform.js";
|
|
21
11
|
import { createTailwindSortFn } from "./sorter.js";
|
|
22
12
|
import { runInit } from "./init.js";
|
|
13
|
+
const USAGE = `Usage:
|
|
14
|
+
tailwind-sort-php [options] [glob ...]
|
|
15
|
+
tailwind-sort-php init [--fix] [--force] [--dry-run]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--stylesheet <path> Tailwind v4 CSS entry (default: tailwindStylesheet from your Prettier config)
|
|
19
|
+
--attr <name> Extra attribute to sort (repeatable; merged with tailwindAttributes)
|
|
20
|
+
--php-source <glob> Also sort class strings in PHP declarations in matching files
|
|
21
|
+
(repeatable; merged with tailwindPhpSources)
|
|
22
|
+
--check Don't write; exit 1 if any file needs sorting
|
|
23
|
+
--no-short-tags Don't treat bare <? as a PHP open tag
|
|
24
|
+
-h, --help Show this help
|
|
25
|
+
--version Print the version
|
|
26
|
+
|
|
27
|
+
Defaults to all .php files under the cwd when no globs are given;
|
|
28
|
+
node_modules, vendor, dist and .git are always skipped.`;
|
|
23
29
|
const IGNORE = ['node_modules', 'vendor', 'dist', '.git'];
|
|
24
30
|
/**
|
|
25
31
|
* Parse command-line arguments.
|
|
@@ -37,18 +43,18 @@ function parseArgs(argv) {
|
|
|
37
43
|
};
|
|
38
44
|
for (let i = 0; i < argv.length; i++) {
|
|
39
45
|
const a = argv[i];
|
|
40
|
-
if (a === '--
|
|
46
|
+
if (a === '--stylesheet')
|
|
47
|
+
cli.stylesheet = requireValue(argv, ++i, a);
|
|
48
|
+
else if (a === '--attr')
|
|
49
|
+
cli.attrs.push(requireValue(argv, ++i, a));
|
|
50
|
+
else if (a === '--php-source')
|
|
51
|
+
cli.phpSources.push(requireValue(argv, ++i, a));
|
|
52
|
+
else if (a === '--check')
|
|
41
53
|
cli.check = true;
|
|
42
54
|
else if (a === '--no-short-tags')
|
|
43
55
|
cli.shortTags = false;
|
|
44
|
-
else if (a === '--stylesheet')
|
|
45
|
-
cli.stylesheet = argv[++i];
|
|
46
|
-
else if (a === '--attr')
|
|
47
|
-
cli.attrs.push(argv[++i]);
|
|
48
|
-
else if (a === '--php-source')
|
|
49
|
-
cli.phpSources.push(argv[++i]);
|
|
50
56
|
else if (a.startsWith('--')) {
|
|
51
|
-
console.error(`Unknown option: ${a}`);
|
|
57
|
+
console.error(`Unknown option: ${a}\n\n${USAGE}`);
|
|
52
58
|
process.exit(2);
|
|
53
59
|
}
|
|
54
60
|
else
|
|
@@ -58,6 +64,17 @@ function parseArgs(argv) {
|
|
|
58
64
|
cli.globs.push('**/*.php');
|
|
59
65
|
return cli;
|
|
60
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Return a flag's value argument or exit with a usage error when it is missing or is itself a flag.
|
|
69
|
+
*/
|
|
70
|
+
function requireValue(argv, i, flag) {
|
|
71
|
+
const v = argv[i];
|
|
72
|
+
if (v === undefined || v.startsWith('--')) {
|
|
73
|
+
console.error(`Missing value for ${flag}`);
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
return v;
|
|
77
|
+
}
|
|
61
78
|
/**
|
|
62
79
|
* Yield file paths matching the given globs, using `Bun.Glob` under Bun and `node:fs` glob (Node >= 22) otherwise.
|
|
63
80
|
*
|
|
@@ -73,8 +90,10 @@ async function* scanFiles(globs) {
|
|
|
73
90
|
}
|
|
74
91
|
else {
|
|
75
92
|
const { glob } = await import('node:fs/promises');
|
|
93
|
+
// Prunes ignored directories during traversal; newer Node versions pass a Dirent instead of a path string.
|
|
94
|
+
const exclude = (entry) => ignored(typeof entry === 'string' ? entry : entry.name);
|
|
76
95
|
for (const pattern of globs) {
|
|
77
|
-
for await (const f of glob(pattern))
|
|
96
|
+
for await (const f of glob(pattern, { exclude }))
|
|
78
97
|
yield f;
|
|
79
98
|
}
|
|
80
99
|
}
|
|
@@ -85,7 +104,7 @@ async function* scanFiles(globs) {
|
|
|
85
104
|
* @param file Path to test, relative to `cwd`.
|
|
86
105
|
* @returns True when the path is inside an ignored directory and should be skipped.
|
|
87
106
|
*/
|
|
88
|
-
const ignored = (file) =>
|
|
107
|
+
const ignored = (file) => file.split(/[\\/]/).some((seg) => IGNORE.includes(seg));
|
|
89
108
|
/**
|
|
90
109
|
* Best-effort read of the resolved Prettier config — the shared source of truth with `prettier-plugin-tailwindcss`.
|
|
91
110
|
* Picks up `tailwindStylesheet`, `tailwindAttributes`, and `tailwindPhpSources`.
|
|
@@ -120,6 +139,16 @@ async function fromPrettierConfig() {
|
|
|
120
139
|
}
|
|
121
140
|
async function main() {
|
|
122
141
|
const argv = process.argv.slice(2);
|
|
142
|
+
// Help/version take precedence everywhere, including after `init`.
|
|
143
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
144
|
+
console.log(USAGE);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (argv.includes('--version')) {
|
|
148
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
149
|
+
console.log(pkg.version);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
123
152
|
if (argv[0] === 'init')
|
|
124
153
|
return runInit(argv.slice(1));
|
|
125
154
|
const cli = parseArgs(argv);
|
package/dist/html.js
CHANGED
|
@@ -47,6 +47,7 @@ export function findClassAttributes(masked, opts = {}) {
|
|
|
47
47
|
const wanted = new Set((opts.attributes ?? ['class', 'classname']).map((a) => a.toLowerCase()));
|
|
48
48
|
const out = [];
|
|
49
49
|
const len = masked.length;
|
|
50
|
+
const lower = masked.toLowerCase();
|
|
50
51
|
let i = 0;
|
|
51
52
|
while (i < len) {
|
|
52
53
|
const lt = masked.indexOf('<', i);
|
|
@@ -75,12 +76,12 @@ export function findClassAttributes(masked, opts = {}) {
|
|
|
75
76
|
let j = lt + 1;
|
|
76
77
|
while (j < len && /[A-Za-z0-9:-]/.test(masked[j]))
|
|
77
78
|
j++;
|
|
78
|
-
const tagName =
|
|
79
|
+
const tagName = lower.slice(lt + 1, j);
|
|
79
80
|
j = scanTagAttributes(masked, j, wanted, out);
|
|
80
81
|
// Skip raw-text element content up to its closing tag.
|
|
81
82
|
if (RAW_TEXT_TAGS.has(tagName)) {
|
|
82
83
|
const closer = `</${tagName}`;
|
|
83
|
-
const idx =
|
|
84
|
+
const idx = lower.indexOf(closer, j);
|
|
84
85
|
j = idx === -1 ? len : idx;
|
|
85
86
|
}
|
|
86
87
|
i = j;
|
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
|
+
}
|
package/dist/php-strings.js
CHANGED
|
@@ -19,8 +19,7 @@
|
|
|
19
19
|
* @see islands.ts - first pass; produces the islands consumed here.
|
|
20
20
|
* @see transform.ts - splices the sorted strings back via the shared byte-replacement path.
|
|
21
21
|
*/
|
|
22
|
-
|
|
23
|
-
const isIdent = (c) => /[A-Za-z0-9_-]/.test(c);
|
|
22
|
+
import { isIdent, isIdentStart, scanHeredoc, scanLineComment, scanQuoted } from "./php-lexer.js";
|
|
24
23
|
/**
|
|
25
24
|
* Find every sortable string-literal value within the given PHP islands.
|
|
26
25
|
*
|
|
@@ -74,11 +73,11 @@ function tokenizeIsland(src, start, end) {
|
|
|
74
73
|
}
|
|
75
74
|
// Line comments (`//`, `#`) — but `#[` opens a PHP 8 attribute, which is code.
|
|
76
75
|
if (c === '/' && src[i + 1] === '/') {
|
|
77
|
-
i =
|
|
76
|
+
i = scanLineComment(src, i + 2, end);
|
|
78
77
|
continue;
|
|
79
78
|
}
|
|
80
79
|
if (c === '#' && src[i + 1] !== '[') {
|
|
81
|
-
i =
|
|
80
|
+
i = scanLineComment(src, i + 1, end);
|
|
82
81
|
continue;
|
|
83
82
|
}
|
|
84
83
|
// Block comment.
|
|
@@ -89,7 +88,7 @@ function tokenizeIsland(src, start, end) {
|
|
|
89
88
|
}
|
|
90
89
|
// Heredoc / nowdoc — never harvested; skip the whole construct.
|
|
91
90
|
if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
|
|
92
|
-
const here =
|
|
91
|
+
const here = scanHeredoc(src, i + 3, end);
|
|
93
92
|
if (here !== -1) {
|
|
94
93
|
i = here;
|
|
95
94
|
pushOther();
|
|
@@ -142,35 +141,6 @@ function tokenizeIsland(src, start, end) {
|
|
|
142
141
|
}
|
|
143
142
|
return tokens;
|
|
144
143
|
}
|
|
145
|
-
/**
|
|
146
|
-
* Scan a quoted string body; `i` is just past the open quote. Returns the offset of the closing quote (or `end`).
|
|
147
|
-
*/
|
|
148
|
-
function scanQuoted(src, i, quote, end) {
|
|
149
|
-
while (i < end) {
|
|
150
|
-
const c = src[i];
|
|
151
|
-
if (c === '\\') {
|
|
152
|
-
i += 2;
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
if (c === quote)
|
|
156
|
-
return i;
|
|
157
|
-
i++;
|
|
158
|
-
}
|
|
159
|
-
return end;
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Skip a `//`/`#` line comment body, ending at a newline or `?>`. Returns the offset to resume scanning from.
|
|
163
|
-
*/
|
|
164
|
-
function skipLineComment(src, i, end) {
|
|
165
|
-
while (i < end) {
|
|
166
|
-
if (src[i] === '\n')
|
|
167
|
-
return i + 1;
|
|
168
|
-
if (src[i] === '?' && src[i + 1] === '>')
|
|
169
|
-
return i;
|
|
170
|
-
i++;
|
|
171
|
-
}
|
|
172
|
-
return end;
|
|
173
|
-
}
|
|
174
144
|
/**
|
|
175
145
|
* True if a double-quoted body contains interpolation (an unescaped `$`) or any escape sequence.
|
|
176
146
|
*/
|
|
@@ -184,47 +154,3 @@ function hasInterpolationOrEscape(body) {
|
|
|
184
154
|
}
|
|
185
155
|
return false;
|
|
186
156
|
}
|
|
187
|
-
/**
|
|
188
|
-
* Skip a heredoc/nowdoc; `i` is just past `<<<`. Returns the offset past the closing identifier line,
|
|
189
|
-
* or -1 if `<<<` isn't followed by a valid heredoc identifier. Mirrors the boundary rules of the island lexer.
|
|
190
|
-
*/
|
|
191
|
-
function skipHeredoc(src, i, end) {
|
|
192
|
-
while (i < end && (src[i] === ' ' || src[i] === '\t'))
|
|
193
|
-
i++;
|
|
194
|
-
let quote = '';
|
|
195
|
-
if (src[i] === "'" || src[i] === '"') {
|
|
196
|
-
quote = src[i];
|
|
197
|
-
i++;
|
|
198
|
-
}
|
|
199
|
-
if (i >= end || !isIdentStart(src[i]))
|
|
200
|
-
return -1;
|
|
201
|
-
const idStart = i;
|
|
202
|
-
while (i < end && isIdent(src[i]))
|
|
203
|
-
i++;
|
|
204
|
-
const id = src.slice(idStart, i);
|
|
205
|
-
if (quote) {
|
|
206
|
-
if (src[i] !== quote)
|
|
207
|
-
return -1;
|
|
208
|
-
i++;
|
|
209
|
-
}
|
|
210
|
-
while (i < end && src[i] === '\r')
|
|
211
|
-
i++;
|
|
212
|
-
if (src[i] !== '\n')
|
|
213
|
-
return -1;
|
|
214
|
-
i++;
|
|
215
|
-
while (i < end) {
|
|
216
|
-
let j = i;
|
|
217
|
-
while (j < end && (src[j] === ' ' || src[j] === '\t'))
|
|
218
|
-
j++;
|
|
219
|
-
if (src.startsWith(id, j)) {
|
|
220
|
-
const k = j + id.length;
|
|
221
|
-
if (k >= end || !isIdent(src[k]))
|
|
222
|
-
return k;
|
|
223
|
-
}
|
|
224
|
-
const nl = src.indexOf('\n', i);
|
|
225
|
-
if (nl === -1 || nl >= end)
|
|
226
|
-
return end;
|
|
227
|
-
i = nl + 1;
|
|
228
|
-
}
|
|
229
|
-
return end;
|
|
230
|
-
}
|
package/dist/transform.js
CHANGED
|
@@ -36,43 +36,64 @@ export function transform(src, sortFn, opts = {}) {
|
|
|
36
36
|
const islands = findIslands(src, opts);
|
|
37
37
|
const masked = maskIslands(src, islands);
|
|
38
38
|
const attrs = findClassAttributes(masked, opts);
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
const edits = [];
|
|
43
|
-
for (const { valueStart, valueEnd } of attrs) {
|
|
44
|
-
const original = src.slice(valueStart, valueEnd);
|
|
45
|
-
const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
|
|
46
|
-
const rewritten = rewriteValue(original, valueStart, inner, sortFn);
|
|
47
|
-
if (rewritten !== original)
|
|
48
|
-
edits.push({ start: valueStart, end: valueEnd, text: rewritten });
|
|
49
|
-
}
|
|
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 = [];
|
|
50
42
|
if (opts.sortPhpStrings) {
|
|
51
43
|
for (const { start, end } of findSortablePhpStrings(src, islands)) {
|
|
52
44
|
const original = src.slice(start, end);
|
|
53
45
|
const tokens = original.split(/\s+/).filter(Boolean);
|
|
54
46
|
if (tokens.length < 2)
|
|
55
47
|
continue; // nothing to reorder; leave byte-identical
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
|
|
48
|
+
const text = sortFn(tokens).join(' ');
|
|
49
|
+
if (text !== original)
|
|
50
|
+
phpEdits.push({ start, end, text });
|
|
59
51
|
}
|
|
60
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) {
|
|
57
|
+
const original = src.slice(valueStart, valueEnd);
|
|
58
|
+
const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
|
|
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);
|
|
65
|
+
}
|
|
66
|
+
for (const e of phpEdits)
|
|
67
|
+
if (!absorbed.has(e))
|
|
68
|
+
edits.push(e);
|
|
61
69
|
edits.sort((a, b) => b.start - a.start);
|
|
62
70
|
let out = src;
|
|
63
71
|
for (const e of edits)
|
|
64
72
|
out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
65
73
|
return out;
|
|
66
74
|
}
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
69
90
|
const parts = [];
|
|
70
91
|
let pos = 0;
|
|
71
92
|
for (const isl of islands) {
|
|
72
93
|
const s = isl.start - base;
|
|
73
94
|
const e = isl.end - base;
|
|
74
95
|
parts.push({ type: 'static', text: value.slice(pos, s) });
|
|
75
|
-
parts.push({ type: 'island', text: value.slice(s, e) });
|
|
96
|
+
parts.push({ type: 'island', text: spliceEdits(value.slice(s, e), isl.start, phpEdits) });
|
|
76
97
|
pos = e;
|
|
77
98
|
}
|
|
78
99
|
parts.push({ type: 'static', text: value.slice(pos) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtimestudio/tailwind-sort-php",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Tailwind CSS Class Sorter for PHP",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"class-sorting",
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/bun": "latest",
|
|
42
|
-
"prettier": "^3.
|
|
42
|
+
"prettier": "^3.9",
|
|
43
43
|
"prettier-plugin-tailwindcss": "^0.8",
|
|
44
|
-
"tailwindcss": "^4",
|
|
44
|
+
"tailwindcss": "^4.3",
|
|
45
45
|
"typescript": ">=5.7"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
package/src/cli.ts
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* tailwind-sort-php CLI
|
|
3
|
+
* tailwind-sort-php CLI — see `USAGE` for the flag reference.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Options:
|
|
10
|
-
* --stylesheet <path> Tailwind v4 CSS entry
|
|
11
|
-
* --attr <name> Extra attribute to sort (repeatable)
|
|
12
|
-
* --php-source <glob> Also sort class strings in PHP declarations in matching files (repeatable)
|
|
13
|
-
* --check Don't write; exit 1 if any file needs sorting
|
|
14
|
-
* --no-short-tags Don't treat bare `<?` as a PHP open tag
|
|
15
|
-
*
|
|
16
|
-
* Defaults to all `.php` files under `cwd` when no globs are given.
|
|
17
|
-
* Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
|
|
5
|
+
* Option values fall back to the resolved Prettier config — the same source of truth `prettier-plugin-tailwindcss`
|
|
6
|
+
* uses; see `fromPrettierConfig()`.
|
|
7
|
+
* The `init` subcommand installs the pre-commit hook; see `init.ts`.
|
|
18
8
|
*/
|
|
19
9
|
|
|
20
10
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
@@ -22,6 +12,23 @@ import { transform, type TransformOptions } from './transform.ts';
|
|
|
22
12
|
import { createTailwindSortFn } from './sorter.ts';
|
|
23
13
|
import { runInit } from './init.ts';
|
|
24
14
|
|
|
15
|
+
const USAGE = `Usage:
|
|
16
|
+
tailwind-sort-php [options] [glob ...]
|
|
17
|
+
tailwind-sort-php init [--fix] [--force] [--dry-run]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--stylesheet <path> Tailwind v4 CSS entry (default: tailwindStylesheet from your Prettier config)
|
|
21
|
+
--attr <name> Extra attribute to sort (repeatable; merged with tailwindAttributes)
|
|
22
|
+
--php-source <glob> Also sort class strings in PHP declarations in matching files
|
|
23
|
+
(repeatable; merged with tailwindPhpSources)
|
|
24
|
+
--check Don't write; exit 1 if any file needs sorting
|
|
25
|
+
--no-short-tags Don't treat bare <? as a PHP open tag
|
|
26
|
+
-h, --help Show this help
|
|
27
|
+
--version Print the version
|
|
28
|
+
|
|
29
|
+
Defaults to all .php files under the cwd when no globs are given;
|
|
30
|
+
node_modules, vendor, dist and .git are always skipped.`;
|
|
31
|
+
|
|
25
32
|
const IGNORE = ['node_modules', 'vendor', 'dist', '.git'];
|
|
26
33
|
|
|
27
34
|
interface Cli {
|
|
@@ -49,13 +56,13 @@ function parseArgs(argv: string[]): Cli {
|
|
|
49
56
|
};
|
|
50
57
|
for (let i = 0; i < argv.length; i++) {
|
|
51
58
|
const a = argv[i];
|
|
52
|
-
if (a === '--
|
|
59
|
+
if (a === '--stylesheet') cli.stylesheet = requireValue(argv, ++i, a);
|
|
60
|
+
else if (a === '--attr') cli.attrs.push(requireValue(argv, ++i, a));
|
|
61
|
+
else if (a === '--php-source') cli.phpSources.push(requireValue(argv, ++i, a));
|
|
62
|
+
else if (a === '--check') cli.check = true;
|
|
53
63
|
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
64
|
else if (a.startsWith('--')) {
|
|
58
|
-
console.error(`Unknown option: ${a}`);
|
|
65
|
+
console.error(`Unknown option: ${a}\n\n${USAGE}`);
|
|
59
66
|
process.exit(2);
|
|
60
67
|
} else cli.globs.push(a);
|
|
61
68
|
}
|
|
@@ -63,6 +70,18 @@ function parseArgs(argv: string[]): Cli {
|
|
|
63
70
|
return cli;
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Return a flag's value argument or exit with a usage error when it is missing or is itself a flag.
|
|
75
|
+
*/
|
|
76
|
+
function requireValue(argv: string[], i: number, flag: string): string {
|
|
77
|
+
const v = argv[i];
|
|
78
|
+
if (v === undefined || v.startsWith('--')) {
|
|
79
|
+
console.error(`Missing value for ${flag}`);
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
return v;
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
/**
|
|
67
86
|
* Yield file paths matching the given globs, using `Bun.Glob` under Bun and `node:fs` glob (Node >= 22) otherwise.
|
|
68
87
|
*
|
|
@@ -76,8 +95,10 @@ async function* scanFiles(globs: string[]): AsyncGenerator<string> {
|
|
|
76
95
|
}
|
|
77
96
|
} else {
|
|
78
97
|
const { glob } = await import('node:fs/promises');
|
|
98
|
+
// Prunes ignored directories during traversal; newer Node versions pass a Dirent instead of a path string.
|
|
99
|
+
const exclude = (entry: string | { name: string }) => ignored(typeof entry === 'string' ? entry : entry.name);
|
|
79
100
|
for (const pattern of globs) {
|
|
80
|
-
for await (const f of glob(pattern)) yield f as string;
|
|
101
|
+
for await (const f of glob(pattern, { exclude })) yield f as string;
|
|
81
102
|
}
|
|
82
103
|
}
|
|
83
104
|
}
|
|
@@ -88,7 +109,7 @@ async function* scanFiles(globs: string[]): AsyncGenerator<string> {
|
|
|
88
109
|
* @param file Path to test, relative to `cwd`.
|
|
89
110
|
* @returns True when the path is inside an ignored directory and should be skipped.
|
|
90
111
|
*/
|
|
91
|
-
const ignored = (file: string) =>
|
|
112
|
+
const ignored = (file: string) => file.split(/[\\/]/).some((seg) => IGNORE.includes(seg));
|
|
92
113
|
|
|
93
114
|
/**
|
|
94
115
|
* Best-effort read of the resolved Prettier config — the shared source of truth with `prettier-plugin-tailwindcss`.
|
|
@@ -128,6 +149,18 @@ async function fromPrettierConfig(): Promise<{
|
|
|
128
149
|
|
|
129
150
|
async function main() {
|
|
130
151
|
const argv = process.argv.slice(2);
|
|
152
|
+
|
|
153
|
+
// Help/version take precedence everywhere, including after `init`.
|
|
154
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
155
|
+
console.log(USAGE);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (argv.includes('--version')) {
|
|
159
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
160
|
+
console.log(pkg.version);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
131
164
|
if (argv[0] === 'init') return runInit(argv.slice(1));
|
|
132
165
|
|
|
133
166
|
const cli = parseArgs(argv);
|
package/src/html.ts
CHANGED
|
@@ -79,6 +79,7 @@ export function findClassAttributes(masked: string, opts: HtmlScanOptions = {}):
|
|
|
79
79
|
const wanted = new Set((opts.attributes ?? ['class', 'classname']).map((a) => a.toLowerCase()));
|
|
80
80
|
const out: ClassAttr[] = [];
|
|
81
81
|
const len = masked.length;
|
|
82
|
+
const lower = masked.toLowerCase();
|
|
82
83
|
let i = 0;
|
|
83
84
|
|
|
84
85
|
while (i < len) {
|
|
@@ -110,14 +111,14 @@ export function findClassAttributes(masked: string, opts: HtmlScanOptions = {}):
|
|
|
110
111
|
if (lt + 1 < len && /[A-Za-z]/.test(masked[lt + 1])) {
|
|
111
112
|
let j = lt + 1;
|
|
112
113
|
while (j < len && /[A-Za-z0-9:-]/.test(masked[j])) j++;
|
|
113
|
-
const tagName =
|
|
114
|
+
const tagName = lower.slice(lt + 1, j);
|
|
114
115
|
|
|
115
116
|
j = scanTagAttributes(masked, j, wanted, out);
|
|
116
117
|
|
|
117
118
|
// Skip raw-text element content up to its closing tag.
|
|
118
119
|
if (RAW_TEXT_TAGS.has(tagName)) {
|
|
119
120
|
const closer = `</${tagName}`;
|
|
120
|
-
const idx =
|
|
121
|
+
const idx = lower.indexOf(closer, j);
|
|
121
122
|
j = idx === -1 ? len : idx;
|
|
122
123
|
}
|
|
123
124
|
i = j;
|
package/src/islands.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* @see html.ts - second pass; consumes islands via `maskIslands()`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { isIdent, scanHeredoc, scanLineComment, scanQuoted } from './php-lexer.ts';
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
16
|
* A contiguous region of PHP code within a mixed template source.
|
|
15
17
|
*/
|
|
@@ -35,9 +37,6 @@ export interface IslandOptions {
|
|
|
35
37
|
shortOpenTags?: boolean;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
const isIdentStart = (c: string) => /[A-Za-z_\u0080-\uffff]/.test(c);
|
|
39
|
-
const isIdent = (c: string) => /[A-Za-z0-9_\u0080-\uffff]/.test(c);
|
|
40
|
-
|
|
41
40
|
/**
|
|
42
41
|
* Find every PHP island in a mixed PHP/HTML template source.
|
|
43
42
|
*
|
|
@@ -99,26 +98,26 @@ function scanPhpBody(src: string, i: number): number {
|
|
|
99
98
|
|
|
100
99
|
// Single-quoted string.
|
|
101
100
|
if (c === "'") {
|
|
102
|
-
i = scanQuoted(src, i + 1, "'");
|
|
101
|
+
i = Math.min(scanQuoted(src, i + 1, "'", len) + 1, len);
|
|
103
102
|
continue;
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
// Double-quoted string.
|
|
107
106
|
// Limitation: nested double quotes in complex `{$a["k"]}` interpolation can desync the lexer; see README.
|
|
108
107
|
if (c === '"') {
|
|
109
|
-
i = scanQuoted(src, i + 1, '"');
|
|
108
|
+
i = Math.min(scanQuoted(src, i + 1, '"', len) + 1, len);
|
|
110
109
|
continue;
|
|
111
110
|
}
|
|
112
111
|
|
|
113
112
|
// Backtick (shell exec) string — same escaping rules.
|
|
114
113
|
if (c === '`') {
|
|
115
|
-
i = scanQuoted(src, i + 1, '`');
|
|
114
|
+
i = Math.min(scanQuoted(src, i + 1, '`', len) + 1, len);
|
|
116
115
|
continue;
|
|
117
116
|
}
|
|
118
117
|
|
|
119
118
|
// Comments.
|
|
120
119
|
if (c === '/' && src[i + 1] === '/') {
|
|
121
|
-
i = scanLineComment(src, i + 2);
|
|
120
|
+
i = scanLineComment(src, i + 2, len);
|
|
122
121
|
if (src.startsWith('?>', i)) return i + 2;
|
|
123
122
|
continue;
|
|
124
123
|
}
|
|
@@ -128,7 +127,7 @@ function scanPhpBody(src: string, i: number): number {
|
|
|
128
127
|
i += 2;
|
|
129
128
|
continue;
|
|
130
129
|
}
|
|
131
|
-
i = scanLineComment(src, i + 1);
|
|
130
|
+
i = scanLineComment(src, i + 1, len);
|
|
132
131
|
if (src.startsWith('?>', i)) return i + 2;
|
|
133
132
|
continue;
|
|
134
133
|
}
|
|
@@ -140,7 +139,7 @@ function scanPhpBody(src: string, i: number): number {
|
|
|
140
139
|
|
|
141
140
|
// Heredoc / nowdoc.
|
|
142
141
|
if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
|
|
143
|
-
const here = scanHeredoc(src, i + 3);
|
|
142
|
+
const here = scanHeredoc(src, i + 3, len);
|
|
144
143
|
if (here !== -1) {
|
|
145
144
|
i = here;
|
|
146
145
|
continue;
|
|
@@ -152,85 +151,3 @@ function scanPhpBody(src: string, i: number): number {
|
|
|
152
151
|
|
|
153
152
|
return len; // file ends while still in PHP mode
|
|
154
153
|
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Scan a quoted string body; `i` is just past the open quote.
|
|
158
|
-
* Returns offset just past the closing quote (or EOF).
|
|
159
|
-
*/
|
|
160
|
-
function scanQuoted(src: string, i: number, quote: string): number {
|
|
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++;
|
|
170
|
-
}
|
|
171
|
-
return len;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Scan a `//` or `#` line comment body, which ends at a newline or at `?>`.
|
|
176
|
-
* Returns the offset past a consumed newline, or the offset of an unconsumed `?>`
|
|
177
|
-
* (left for the caller, since `?>` closes both the comment and the island).
|
|
178
|
-
*/
|
|
179
|
-
function scanLineComment(src: string, i: number): number {
|
|
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
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Scan a heredoc/nowdoc; `i` is just past `<<<`.
|
|
191
|
-
* Returns the offset past the closing identifier line, or -1 if `<<<` isn't followed by a valid heredoc identifier.
|
|
192
|
-
*/
|
|
193
|
-
function scanHeredoc(src: string, i: number): number {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
219
|
-
i++;
|
|
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;
|
|
234
|
-
}
|
|
235
|
-
return len;
|
|
236
|
-
}
|
package/src/php-lexer.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
|
|
10
|
+
export const isIdentStart = (c: string) => /[A-Za-z_\u0080-\uffff]/.test(c);
|
|
11
|
+
export const isIdent = (c: string) => /[A-Za-z0-9_\u0080-\uffff]/.test(c);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Scan a quoted string body; `i` is just past the open quote.
|
|
15
|
+
*
|
|
16
|
+
* @returns The offset of the closing quote, or `end` when unterminated.
|
|
17
|
+
*/
|
|
18
|
+
export function scanQuoted(src: string, i: number, quote: string, end: number): number {
|
|
19
|
+
while (i < end) {
|
|
20
|
+
const c = src[i];
|
|
21
|
+
if (c === '\\') {
|
|
22
|
+
i += 2;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (c === quote) return i;
|
|
26
|
+
i++;
|
|
27
|
+
}
|
|
28
|
+
return end;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Scan a `//` or `#` line comment body, which ends at a newline or at `?>`.
|
|
33
|
+
*
|
|
34
|
+
* @returns The offset past a consumed newline, or the offset of an unconsumed `?>`
|
|
35
|
+
* (left for the caller, since `?>` closes both the comment and the island).
|
|
36
|
+
*/
|
|
37
|
+
export function scanLineComment(src: string, i: number, end: number): number {
|
|
38
|
+
while (i < end) {
|
|
39
|
+
if (src[i] === '\n') return i + 1;
|
|
40
|
+
if (src[i] === '?' && src[i + 1] === '>') return i;
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
return end;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Scan a heredoc/nowdoc; `i` is just past `<<<`.
|
|
48
|
+
*
|
|
49
|
+
* @returns The offset past the closing identifier, or -1 if `<<<` isn't followed by a valid heredoc identifier.
|
|
50
|
+
*/
|
|
51
|
+
export function scanHeredoc(src: string, i: number, end: number): number {
|
|
52
|
+
// Optional whitespace (PHP allows spaces/tabs after `<<<`).
|
|
53
|
+
while (i < end && (src[i] === ' ' || src[i] === '\t')) i++;
|
|
54
|
+
|
|
55
|
+
// Optional quote around the identifier.
|
|
56
|
+
let quote = '';
|
|
57
|
+
if (src[i] === "'" || src[i] === '"') {
|
|
58
|
+
quote = src[i];
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (i >= end || !isIdentStart(src[i])) return -1;
|
|
63
|
+
const idStart = i;
|
|
64
|
+
while (i < end && isIdent(src[i])) i++;
|
|
65
|
+
const id = src.slice(idStart, i);
|
|
66
|
+
|
|
67
|
+
if (quote) {
|
|
68
|
+
if (src[i] !== quote) return -1;
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// After the identifier, the line must end immediately — PHP disallows trailing whitespace,
|
|
73
|
+
// but we tolerate `\r` so `\r\n` endings still work.
|
|
74
|
+
while (i < end && src[i] === '\r') i++;
|
|
75
|
+
if (src[i] !== '\n') return -1;
|
|
76
|
+
i++;
|
|
77
|
+
|
|
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')) j++;
|
|
84
|
+
if (src.startsWith(id, j)) {
|
|
85
|
+
const k = j + id.length;
|
|
86
|
+
if (k >= end || !isIdent(src[k])) return k;
|
|
87
|
+
}
|
|
88
|
+
const nl = src.indexOf('\n', i);
|
|
89
|
+
if (nl === -1 || nl >= end) return end;
|
|
90
|
+
i = nl + 1;
|
|
91
|
+
}
|
|
92
|
+
return end;
|
|
93
|
+
}
|
package/src/php-strings.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type { Island } from './islands.ts';
|
|
24
|
+
import { isIdent, isIdentStart, scanHeredoc, scanLineComment, scanQuoted } from './php-lexer.ts';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Inner byte range of a sortable string literal — the span *between* the quotes, in original-source offsets.
|
|
@@ -36,9 +37,6 @@ export interface PhpStringRange {
|
|
|
36
37
|
end: number;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
const isIdentStart = (c: string) => /[A-Za-z_-]/.test(c);
|
|
40
|
-
const isIdent = (c: string) => /[A-Za-z0-9_-]/.test(c);
|
|
41
|
-
|
|
42
40
|
interface Token {
|
|
43
41
|
kind: 'string' | 'arrow' | 'dot' | 'other';
|
|
44
42
|
/**
|
|
@@ -105,11 +103,11 @@ function tokenizeIsland(src: string, start: number, end: number): Token[] {
|
|
|
105
103
|
|
|
106
104
|
// Line comments (`//`, `#`) — but `#[` opens a PHP 8 attribute, which is code.
|
|
107
105
|
if (c === '/' && src[i + 1] === '/') {
|
|
108
|
-
i =
|
|
106
|
+
i = scanLineComment(src, i + 2, end);
|
|
109
107
|
continue;
|
|
110
108
|
}
|
|
111
109
|
if (c === '#' && src[i + 1] !== '[') {
|
|
112
|
-
i =
|
|
110
|
+
i = scanLineComment(src, i + 1, end);
|
|
113
111
|
continue;
|
|
114
112
|
}
|
|
115
113
|
|
|
@@ -122,7 +120,7 @@ function tokenizeIsland(src: string, start: number, end: number): Token[] {
|
|
|
122
120
|
|
|
123
121
|
// Heredoc / nowdoc — never harvested; skip the whole construct.
|
|
124
122
|
if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
|
|
125
|
-
const here =
|
|
123
|
+
const here = scanHeredoc(src, i + 3, end);
|
|
126
124
|
if (here !== -1) {
|
|
127
125
|
i = here;
|
|
128
126
|
pushOther();
|
|
@@ -178,34 +176,6 @@ function tokenizeIsland(src: string, start: number, end: number): Token[] {
|
|
|
178
176
|
return tokens;
|
|
179
177
|
}
|
|
180
178
|
|
|
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
179
|
/**
|
|
210
180
|
* True if a double-quoted body contains interpolation (an unescaped `$`) or any escape sequence.
|
|
211
181
|
*/
|
|
@@ -217,44 +187,3 @@ function hasInterpolationOrEscape(body: string): boolean {
|
|
|
217
187
|
}
|
|
218
188
|
return false;
|
|
219
189
|
}
|
|
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/transform.ts
CHANGED
|
@@ -55,27 +55,33 @@ export function transform(src: string, sortFn: SortFn, opts: TransformOptions =
|
|
|
55
55
|
const masked = maskIslands(src, islands);
|
|
56
56
|
const attrs = findClassAttributes(masked, opts);
|
|
57
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
|
+
|
|
58
71
|
// Collect every edit as a {start, end, text} range, then apply them back-to-front so offsets stay valid.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const edits: { start: number; end: number; text: string }[] = [];
|
|
72
|
+
const edits: Edit[] = [];
|
|
73
|
+
const absorbed = new Set<Edit>();
|
|
62
74
|
|
|
63
75
|
for (const { valueStart, valueEnd } of attrs) {
|
|
64
76
|
const original = src.slice(valueStart, valueEnd);
|
|
65
77
|
const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
|
|
66
|
-
const
|
|
78
|
+
const innerEdits = phpEdits.filter((e) => e.start >= valueStart && e.end <= valueEnd);
|
|
79
|
+
const rewritten = rewriteValue(original, valueStart, inner, sortFn, innerEdits);
|
|
67
80
|
if (rewritten !== original) edits.push({ start: valueStart, end: valueEnd, text: rewritten });
|
|
81
|
+
for (const e of innerEdits) absorbed.add(e);
|
|
68
82
|
}
|
|
69
83
|
|
|
70
|
-
if (
|
|
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
|
-
}
|
|
78
|
-
}
|
|
84
|
+
for (const e of phpEdits) if (!absorbed.has(e)) edits.push(e);
|
|
79
85
|
|
|
80
86
|
edits.sort((a, b) => b.start - a.start);
|
|
81
87
|
let out = src;
|
|
@@ -83,20 +89,39 @@ export function transform(src: string, sortFn: SortFn, opts: TransformOptions =
|
|
|
83
89
|
return out;
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
interface Edit {
|
|
93
|
+
start: number;
|
|
94
|
+
end: number;
|
|
95
|
+
text: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
interface Part {
|
|
87
99
|
type: 'static' | 'island';
|
|
88
100
|
text: string;
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
|
|
92
|
-
|
|
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);
|
|
112
|
+
}
|
|
113
|
+
return text;
|
|
114
|
+
}
|
|
115
|
+
|
|
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.
|
|
93
118
|
const parts: Part[] = [];
|
|
94
119
|
let pos = 0;
|
|
95
120
|
for (const isl of islands) {
|
|
96
121
|
const s = isl.start - base;
|
|
97
122
|
const e = isl.end - base;
|
|
98
123
|
parts.push({ type: 'static', text: value.slice(pos, s) });
|
|
99
|
-
parts.push({ type: 'island', text: value.slice(s, e) });
|
|
124
|
+
parts.push({ type: 'island', text: spliceEdits(value.slice(s, e), isl.start, phpEdits) });
|
|
100
125
|
pos = e;
|
|
101
126
|
}
|
|
102
127
|
parts.push({ type: 'static', text: value.slice(pos) });
|