@runtimestudio/tailwind-sort-php 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
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
+ const isIdentStart = (c) => /[A-Za-z_€-￿]/.test(c);
23
+ const isIdent = (c) => /[A-Za-z0-9_€-￿]/.test(c);
24
+ /**
25
+ * Find every sortable string-literal value within the given PHP islands.
26
+ *
27
+ * @param src Original template source.
28
+ * @param islands Island ranges from `findIslands()`.
29
+ * @returns Inner ranges of eligible string values, in document order.
30
+ *
31
+ * @example
32
+ * const islands = findIslands(`<?php $x = 'z a'; ?>`);
33
+ * findSortablePhpStrings(`<?php $x = 'z a'; ?>`, islands); // [{ start: 11, end: 14 }]
34
+ */
35
+ export function findSortablePhpStrings(src, islands) {
36
+ const ranges = [];
37
+ for (const isl of islands) {
38
+ const tokens = tokenizeIsland(src, isl.start, isl.end);
39
+ for (let k = 0; k < tokens.length; k++) {
40
+ const token = tokens[k];
41
+ if (token.kind !== 'string' || token.skip)
42
+ continue;
43
+ const next = tokens[k + 1];
44
+ const prev = tokens[k - 1];
45
+ // Array key (`'left' => ...`) — never sorted.
46
+ if (next && next.kind === 'arrow')
47
+ continue;
48
+ // Part of a concatenation expression (`'btn-' . $v` / `$v . 'suffix'`).
49
+ if ((next && next.kind === 'dot') || (prev && prev.kind === 'dot'))
50
+ continue;
51
+ ranges.push({ start: token.start, end: token.end });
52
+ }
53
+ }
54
+ return ranges;
55
+ }
56
+ /**
57
+ * Tokenize one PHP island into the minimal token stream needed for value classification: string literals
58
+ * (with their inner range and a skip flag), the `=>` arrow, the `.` concatenation operator, and a coalesced `other`
59
+ * marker for everything else. Whitespace and comments are dropped so adjacency is judged across them.
60
+ */
61
+ function tokenizeIsland(src, start, end) {
62
+ const tokens = [];
63
+ const pushOther = () => {
64
+ if (tokens.length === 0 || tokens[tokens.length - 1].kind !== 'other')
65
+ tokens.push({ kind: 'other' });
66
+ };
67
+ let i = start;
68
+ while (i < end) {
69
+ const c = src[i];
70
+ // Whitespace.
71
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v') {
72
+ i++;
73
+ continue;
74
+ }
75
+ // Line comments (`//`, `#`) — but `#[` opens a PHP 8 attribute, which is code.
76
+ if (c === '/' && src[i + 1] === '/') {
77
+ i = skipLineComment(src, i + 2, end);
78
+ continue;
79
+ }
80
+ if (c === '#' && src[i + 1] !== '[') {
81
+ i = skipLineComment(src, i + 1, end);
82
+ continue;
83
+ }
84
+ // Block comment.
85
+ if (c === '/' && src[i + 1] === '*') {
86
+ const close = src.indexOf('*/', i + 2);
87
+ i = close === -1 || close + 2 > end ? end : close + 2;
88
+ continue;
89
+ }
90
+ // Heredoc / nowdoc — never harvested; skip the whole construct.
91
+ if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
92
+ const here = skipHeredoc(src, i + 3, end);
93
+ if (here !== -1) {
94
+ i = here;
95
+ pushOther();
96
+ continue;
97
+ }
98
+ }
99
+ // Quoted strings.
100
+ if (c === "'") {
101
+ const close = scanQuoted(src, i + 1, "'", end);
102
+ const inner = src.slice(i + 1, close);
103
+ tokens.push({ kind: 'string', start: i + 1, end: close, skip: inner.includes('\\') });
104
+ i = close + 1;
105
+ continue;
106
+ }
107
+ if (c === '"') {
108
+ const close = scanQuoted(src, i + 1, '"', end);
109
+ const inner = src.slice(i + 1, close);
110
+ // Skip interpolation (any unescaped `$`) and escapes.
111
+ tokens.push({ kind: 'string', start: i + 1, end: close, skip: hasInterpolationOrEscape(inner) });
112
+ i = close + 1;
113
+ continue;
114
+ }
115
+ if (c === '`') {
116
+ // Shell-exec string — never a class list.
117
+ i = scanQuoted(src, i + 1, '`', end) + 1;
118
+ pushOther();
119
+ continue;
120
+ }
121
+ // Operators that matter for classification.
122
+ if (c === '=' && src[i + 1] === '>') {
123
+ tokens.push({ kind: 'arrow' });
124
+ i += 2;
125
+ continue;
126
+ }
127
+ if (c === '.') {
128
+ tokens.push({ kind: 'dot' });
129
+ i++;
130
+ continue;
131
+ }
132
+ // Everything else (identifiers, punctuation, the `<?php`/`?>` tags themselves).
133
+ pushOther();
134
+ if (isIdentStart(c)) {
135
+ i++;
136
+ while (i < end && isIdent(src[i]))
137
+ i++;
138
+ }
139
+ else {
140
+ i++;
141
+ }
142
+ }
143
+ return tokens;
144
+ }
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
+ /**
175
+ * True if a double-quoted body contains interpolation (an unescaped `$`) or any escape sequence.
176
+ */
177
+ function hasInterpolationOrEscape(body) {
178
+ for (let i = 0; i < body.length; i++) {
179
+ const c = body[i];
180
+ if (c === '\\')
181
+ return true;
182
+ if (c === '$')
183
+ return true;
184
+ }
185
+ return false;
186
+ }
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/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 (`@import "tailwindcss"`, `@theme`, etc.).
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, configured with
24
- * the project's Tailwind v4 stylesheet so custom `@theme` tokens and `@utility` classes sort correctly.
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, configured with
10
- * the project's Tailwind v4 stylesheet so custom `@theme` tokens and `@utility` classes sort correctly.
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(),
@@ -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 both lexer passes.
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,17 +36,32 @@ 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
- // Apply replacements back-to-front so offsets stay valid.
39
- let out = src;
40
- for (let a = attrs.length - 1; a >= 0; a--) {
41
- const { valueStart, valueEnd } = attrs[a];
39
+ // Collect every edit as a {start, end, text} range, then apply them back-to-front so offsets stay valid.
40
+ // HTML class-attribute values live outside islands; PHP string values live inside them, so the two sets of
41
+ // ranges never overlap.
42
+ const edits = [];
43
+ for (const { valueStart, valueEnd } of attrs) {
42
44
  const original = src.slice(valueStart, valueEnd);
43
45
  const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
44
46
  const rewritten = rewriteValue(original, valueStart, inner, sortFn);
45
- if (rewritten !== original) {
46
- out = out.slice(0, valueStart) + rewritten + out.slice(valueEnd);
47
+ if (rewritten !== original)
48
+ edits.push({ start: valueStart, end: valueEnd, text: rewritten });
49
+ }
50
+ if (opts.sortPhpStrings) {
51
+ for (const { start, end } of findSortablePhpStrings(src, islands)) {
52
+ const original = src.slice(start, end);
53
+ const tokens = original.split(/\s+/).filter(Boolean);
54
+ if (tokens.length < 2)
55
+ continue; // nothing to reorder; leave byte-identical
56
+ const rewritten = sortFn(tokens).join(' ');
57
+ if (rewritten !== original)
58
+ edits.push({ start, end, text: rewritten });
47
59
  }
48
60
  }
61
+ edits.sort((a, b) => b.start - a.start);
62
+ let out = src;
63
+ for (const e of edits)
64
+ out = out.slice(0, e.start) + e.text + out.slice(e.end);
49
65
  return out;
50
66
  }
51
67
  function rewriteValue(value, base, islands, sortFn) {
package/package.json CHANGED
@@ -1,57 +1,57 @@
1
1
  {
2
- "name": "@runtimestudio/tailwind-sort-php",
3
- "version": "0.2.0",
4
- "description": "Tailwind CSS Class Sorter for PHP",
5
- "keywords": [
6
- "class-sorting",
7
- "formatter",
8
- "php",
9
- "prettier",
10
- "tailwindcss",
11
- "wordpress"
12
- ],
13
- "homepage": "https://github.com/runtime-studio-au/tailwind-sort-php#readme",
14
- "bugs": "https://github.com/runtime-studio-au/tailwind-sort-php/issues",
15
- "license": "MIT",
16
- "author": "Greg Sevastos <greg@runtimestudio.com.au> (https://runtimestudio.com.au)",
17
- "files": [
18
- "dist",
19
- "src"
20
- ],
21
- "main": "dist/index.js",
22
- "types": "dist/index.d.ts",
23
- "type": "module",
24
- "bin": {
25
- "tailwind-sort-php": "dist/cli.js"
26
- },
27
- "repository": {
28
- "type": "git",
29
- "url": "git+https://github.com/runtime-studio-au/tailwind-sort-php.git"
30
- },
31
- "scripts": {
32
- "build": "tsc -p tsconfig.build.json",
33
- "format": "prettier --write .",
34
- "format:check": "prettier --check .",
35
- "prepublishOnly": "npm test && npm run build",
36
- "sort": "bun run src/cli.ts",
37
- "sort:check": "bun run src/cli.ts --check",
38
- "test": "node --test \"test/*.test.ts\""
39
- },
40
- "devDependencies": {
41
- "@types/bun": "latest",
42
- "prettier": "^3.8",
43
- "prettier-plugin-tailwindcss": "^0.8",
44
- "tailwindcss": "^4",
45
- "typescript": ">=5.7"
46
- },
47
- "peerDependencies": {
48
- "prettier": ">=3",
49
- "prettier-plugin-tailwindcss": ">=0.8"
50
- },
51
- "engines": {
52
- "node": ">=22.18"
53
- },
54
- "publishConfig": {
55
- "access": "public"
56
- }
2
+ "name": "@runtimestudio/tailwind-sort-php",
3
+ "version": "0.3.0",
4
+ "description": "Tailwind CSS Class Sorter for PHP",
5
+ "keywords": [
6
+ "class-sorting",
7
+ "formatter",
8
+ "php",
9
+ "prettier",
10
+ "tailwindcss",
11
+ "wordpress"
12
+ ],
13
+ "homepage": "https://github.com/runtime-studio-au/tailwind-sort-php#readme",
14
+ "bugs": "https://github.com/runtime-studio-au/tailwind-sort-php/issues",
15
+ "license": "MIT",
16
+ "author": "Greg Sevastos <greg@runtimestudio.com.au> (https://runtimestudio.com.au)",
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "main": "dist/index.js",
22
+ "types": "dist/index.d.ts",
23
+ "type": "module",
24
+ "bin": {
25
+ "tailwind-sort-php": "dist/cli.js"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/runtime-studio-au/tailwind-sort-php.git"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.build.json",
33
+ "format": "prettier --write .",
34
+ "format:check": "prettier --check .",
35
+ "prepublishOnly": "npm test && npm run build",
36
+ "sort": "bun run src/cli.ts",
37
+ "sort:check": "bun run src/cli.ts --check",
38
+ "test": "node --test \"test/*.test.ts\""
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "latest",
42
+ "prettier": "^3.8",
43
+ "prettier-plugin-tailwindcss": "^0.8",
44
+ "tailwindcss": "^4",
45
+ "typescript": ">=5.7"
46
+ },
47
+ "peerDependencies": {
48
+ "prettier": ">=3",
49
+ "prettier-plugin-tailwindcss": ">=0.8"
50
+ },
51
+ "engines": {
52
+ "node": ">=22.18"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
57
  }