@runtimestudio/tailwind-sort-php 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +68,7 @@ bunx tailwind-sort-php --stylesheet ./resources/css/main.css
68
68
  ### Options
69
69
 
70
70
  | Flag | Description |
71
- | --------------------- | ------------------------------------------------------------------------------------------------- |
71
+ |-----------------------|---------------------------------------------------------------------------------------------------|
72
72
  | `--stylesheet <path>` | Tailwind v4 CSS entry. Defaults to `tailwindStylesheet` from your Prettier config. |
73
73
  | `--attr <name>` | Extra attribute to sort (repeatable). Merged with `tailwindAttributes` from your Prettier config. |
74
74
  | `--check` | Don't write; exit 1 if any file needs sorting. |
@@ -131,10 +131,12 @@ const out = transform(source, sortFn);
131
131
 
132
132
  ```sh
133
133
  bun test # or: node --test "test/*.test.ts"
134
+ bun run build # compile src → dist (tsc); the published artifact
134
135
  ```
135
136
 
136
- 41 tests, zero dependencies the sorter is injected (`SortFn`), so tests run with a mock alphabetical sorter and the
137
- official sorter is only loaded by the CLI.
137
+ 46 tests: 41 core tests that are dependency-free (the sorter is injected, so they run against a mock `SortFn`), plus 5
138
+ integration tests that exercise the real `prettier-plugin-tailwindcss` sorter and skip automatically when the Tailwind
139
+ toolchain isn't installed.
138
140
 
139
141
  ## License
140
142
 
package/dist/cli.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tailwind-sort-php CLI
4
+ *
5
+ * Usage:
6
+ * tailwind-sort-php [options] [glob ...]
7
+ *
8
+ * Options:
9
+ * --stylesheet <path> Tailwind v4 CSS entry
10
+ * --attr <name> Extra attribute to sort (repeatable)
11
+ * --check Don't write; exit 1 if any file needs sorting
12
+ * --no-short-tags Don't treat bare `<?` as a PHP open tag
13
+ *
14
+ * Defaults to all `.php` files under `cwd` (`"**" + "/*.php"`) when no globs are given.
15
+ * Skips `node_modules`, `vendor`, `dist` and `.git`.
16
+ */
17
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tailwind-sort-php CLI
4
+ *
5
+ * Usage:
6
+ * tailwind-sort-php [options] [glob ...]
7
+ *
8
+ * Options:
9
+ * --stylesheet <path> Tailwind v4 CSS entry
10
+ * --attr <name> Extra attribute to sort (repeatable)
11
+ * --check Don't write; exit 1 if any file needs sorting
12
+ * --no-short-tags Don't treat bare `<?` as a PHP open tag
13
+ *
14
+ * Defaults to all `.php` files under `cwd` (`"**" + "/*.php"`) when no globs are given.
15
+ * Skips `node_modules`, `vendor`, `dist` and `.git`.
16
+ */
17
+ import { readFile, writeFile } from 'node:fs/promises';
18
+ import { transform } from "./transform.js";
19
+ import { createTailwindSortFn } from "./sorter.js";
20
+ const IGNORE = ['node_modules', 'vendor', 'dist', '.git'];
21
+ /**
22
+ * Parse command-line arguments.
23
+ *
24
+ * @param argv Arguments after the runtime and script path.
25
+ * @returns Parsed CLI options with defaults applied.
26
+ */
27
+ function parseArgs(argv) {
28
+ const cli = {
29
+ globs: [],
30
+ attrs: ['class', 'className'],
31
+ check: false,
32
+ shortTags: true,
33
+ };
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (a === '--check')
37
+ cli.check = true;
38
+ else if (a === '--no-short-tags')
39
+ cli.shortTags = false;
40
+ else if (a === '--stylesheet')
41
+ cli.stylesheet = argv[++i];
42
+ else if (a === '--attr')
43
+ cli.attrs.push(argv[++i]);
44
+ else if (a.startsWith('--')) {
45
+ console.error(`Unknown option: ${a}`);
46
+ process.exit(2);
47
+ }
48
+ else
49
+ cli.globs.push(a);
50
+ }
51
+ if (cli.globs.length === 0)
52
+ cli.globs.push('**/*.php');
53
+ return cli;
54
+ }
55
+ /**
56
+ * Yield file paths matching the given globs, using `Bun.Glob` under Bun and `node:fs` glob (Node >= 22) otherwise.
57
+ *
58
+ * @param globs Glob patterns relative to `cwd`.
59
+ */
60
+ async function* scanFiles(globs) {
61
+ // Use `Bun.Glob` when available, fall back to `node:fs` glob (Node 22+).
62
+ if (typeof globalThis.Bun !== 'undefined') {
63
+ const { Glob } = await import('bun');
64
+ for (const pattern of globs) {
65
+ for await (const f of new Glob(pattern).scan('.'))
66
+ yield f;
67
+ }
68
+ }
69
+ else {
70
+ const { glob } = await import('node:fs/promises');
71
+ for (const pattern of globs) {
72
+ for await (const f of glob(pattern))
73
+ yield f;
74
+ }
75
+ }
76
+ }
77
+ /**
78
+ * Whether a path falls inside an always-ignored directory.
79
+ *
80
+ * @param file Path to test, relative to `cwd`.
81
+ * @returns True when the path is inside an ignored directory and should be skipped.
82
+ */
83
+ const ignored = (file) => IGNORE.some((d) => file.includes(`${d}/`) || file.startsWith(d));
84
+ /**
85
+ * Best-effort read of the project's resolved Prettier config, so this tool shares one source of truth with
86
+ * `prettier-plugin-tailwindcss`. Picks up `tailwindStylesheet` (resolved relative to the config file) and
87
+ * `tailwindAttributes` (merged into the attribute list).
88
+ *
89
+ * @returns The resolved stylesheet path and attributes, or an empty object if none are available.
90
+ */
91
+ async function fromPrettierConfig() {
92
+ try {
93
+ const prettier = await import('prettier');
94
+ const { dirname, resolve } = await import('node:path');
95
+ const configFile = await prettier.resolveConfigFile();
96
+ if (!configFile)
97
+ return {};
98
+ const cfg = (await prettier.resolveConfig(configFile));
99
+ if (!cfg)
100
+ return {};
101
+ const out = {};
102
+ if (typeof cfg.tailwindStylesheet === 'string') {
103
+ out.stylesheet = resolve(dirname(configFile), cfg.tailwindStylesheet);
104
+ }
105
+ if (Array.isArray(cfg.tailwindAttributes)) {
106
+ out.attributes = cfg.tailwindAttributes.filter((a) => typeof a === 'string' && !a.startsWith('/'));
107
+ }
108
+ return out;
109
+ }
110
+ catch {
111
+ return {}; // prettier not installed or config unreadable — flags only
112
+ }
113
+ }
114
+ async function main() {
115
+ const cli = parseArgs(process.argv.slice(2));
116
+ const pc = await fromPrettierConfig();
117
+ const stylesheet = cli.stylesheet ?? pc.stylesheet;
118
+ if (!stylesheet) {
119
+ console.error('No Tailwind stylesheet found. Pass --stylesheet <path> or set ' +
120
+ '`tailwindStylesheet` in your Prettier config.');
121
+ process.exit(2);
122
+ }
123
+ if (pc.attributes) {
124
+ for (const a of pc.attributes) {
125
+ if (!cli.attrs.includes(a))
126
+ cli.attrs.push(a);
127
+ }
128
+ }
129
+ const sortFn = await createTailwindSortFn({ stylesheet });
130
+ const opts = {
131
+ attributes: cli.attrs,
132
+ shortOpenTags: cli.shortTags,
133
+ };
134
+ let scanned = 0;
135
+ let changed = 0;
136
+ for (const pattern of cli.globs) {
137
+ for await (const file of scanFiles([pattern])) {
138
+ if (ignored(file))
139
+ continue;
140
+ scanned++;
141
+ const src = await readFile(file, 'utf8');
142
+ const out = transform(src, sortFn, opts);
143
+ if (out !== src) {
144
+ changed++;
145
+ if (cli.check) {
146
+ console.log(`needs sorting: ${file}`);
147
+ }
148
+ else {
149
+ await writeFile(file, out);
150
+ console.log(`sorted: ${file}`);
151
+ }
152
+ }
153
+ }
154
+ }
155
+ console.log(`${scanned} file(s) scanned, ${changed} ${cli.check ? 'need(s) sorting' : 'updated'}`);
156
+ if (cli.check && changed > 0)
157
+ process.exit(1);
158
+ }
159
+ main().catch((err) => {
160
+ console.error(err);
161
+ process.exit(1);
162
+ });
package/dist/html.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * HTML attribute scanner — the second pass of the two-pass lexer.
3
+ *
4
+ * Operates on a "masked" copy of the source in which every PHP island byte has been replaced with `\x00`
5
+ * (offsets preserved). This means:
6
+ * - quotes/angle brackets inside PHP can never confuse the HTML scan
7
+ * - islands inside a tag read as attribute separators
8
+ * - islands inside a quoted attribute value read as opaque atoms
9
+ *
10
+ * Skips HTML comments, doctype/CDATA, and the raw-text content of `script`/`style`/`textarea`/`title` elements —
11
+ * their `script`/`style` content may contain strings like `class="..."` that must not be touched.
12
+ *
13
+ * @see islands.ts - first pass; produces the islands consumed here.
14
+ */
15
+ import type { Island } from './islands.ts';
16
+ /**
17
+ * Location of a sortable class attribute value within the source.
18
+ */
19
+ export interface ClassAttr {
20
+ /**
21
+ * Attribute name as written (e.g. `class`, `className`).
22
+ */
23
+ name: string;
24
+ /**
25
+ * Offset of the first character inside the quotes.
26
+ */
27
+ valueStart: number;
28
+ /**
29
+ * Offset just past the last character inside the quotes (exclusive).
30
+ */
31
+ valueEnd: number;
32
+ }
33
+ /**
34
+ * Options controlling which attributes are collected.
35
+ */
36
+ export interface HtmlScanOptions {
37
+ /**
38
+ * Lowercase attribute names to collect; default `['class', 'classname']`.
39
+ */
40
+ attributes?: string[];
41
+ }
42
+ /**
43
+ * Produce a copy of the source with every island byte replaced by `\x00`.
44
+ *
45
+ * Length and offsets are preserved, so positions found in the masked string map 1:1 back to the original source.
46
+ *
47
+ * @param src Original template source.
48
+ * @param islands Island ranges from `findIslands()`.
49
+ * @returns Masked source of identical length.
50
+ */
51
+ export declare function maskIslands(src: string, islands: Island[]): string;
52
+ /**
53
+ * Locate every sortable class attribute in an island-masked source.
54
+ *
55
+ * @param masked Source pre-processed by `maskIslands()`.
56
+ * @param opts Attribute collection options.
57
+ * @returns Attribute value locations in document order. Offsets index into the original source.
58
+ */
59
+ export declare function findClassAttributes(masked: string, opts?: HtmlScanOptions): ClassAttr[];
package/dist/html.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * HTML attribute scanner — the second pass of the two-pass lexer.
3
+ *
4
+ * Operates on a "masked" copy of the source in which every PHP island byte has been replaced with `\x00`
5
+ * (offsets preserved). This means:
6
+ * - quotes/angle brackets inside PHP can never confuse the HTML scan
7
+ * - islands inside a tag read as attribute separators
8
+ * - islands inside a quoted attribute value read as opaque atoms
9
+ *
10
+ * Skips HTML comments, doctype/CDATA, and the raw-text content of `script`/`style`/`textarea`/`title` elements —
11
+ * their `script`/`style` content may contain strings like `class="..."` that must not be touched.
12
+ *
13
+ * @see islands.ts - first pass; produces the islands consumed here.
14
+ */
15
+ const RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title']);
16
+ const NUL = '\x00';
17
+ /**
18
+ * Produce a copy of the source with every island byte replaced by `\x00`.
19
+ *
20
+ * Length and offsets are preserved, so positions found in the masked string map 1:1 back to the original source.
21
+ *
22
+ * @param src Original template source.
23
+ * @param islands Island ranges from `findIslands()`.
24
+ * @returns Masked source of identical length.
25
+ */
26
+ export function maskIslands(src, islands) {
27
+ if (islands.length === 0)
28
+ return src;
29
+ let out = '';
30
+ let pos = 0;
31
+ for (const isl of islands) {
32
+ out += src.slice(pos, isl.start);
33
+ out += NUL.repeat(isl.end - isl.start);
34
+ pos = isl.end;
35
+ }
36
+ out += src.slice(pos);
37
+ return out;
38
+ }
39
+ /**
40
+ * Locate every sortable class attribute in an island-masked source.
41
+ *
42
+ * @param masked Source pre-processed by `maskIslands()`.
43
+ * @param opts Attribute collection options.
44
+ * @returns Attribute value locations in document order. Offsets index into the original source.
45
+ */
46
+ export function findClassAttributes(masked, opts = {}) {
47
+ const wanted = new Set((opts.attributes ?? ['class', 'classname']).map((a) => a.toLowerCase()));
48
+ const out = [];
49
+ const len = masked.length;
50
+ let i = 0;
51
+ while (i < len) {
52
+ const lt = masked.indexOf('<', i);
53
+ if (lt === -1)
54
+ break;
55
+ // HTML comment.
56
+ if (masked.startsWith('<!--', lt)) {
57
+ const close = masked.indexOf('-->', lt + 4);
58
+ i = close === -1 ? len : close + 3;
59
+ continue;
60
+ }
61
+ // Doctype / CDATA / other declarations.
62
+ if (masked[lt + 1] === '!') {
63
+ const close = masked.indexOf('>', lt + 2);
64
+ i = close === -1 ? len : close + 1;
65
+ continue;
66
+ }
67
+ // Closing tag.
68
+ if (masked[lt + 1] === '/') {
69
+ const close = masked.indexOf('>', lt + 2);
70
+ i = close === -1 ? len : close + 1;
71
+ continue;
72
+ }
73
+ // Opening tag?
74
+ if (lt + 1 < len && /[A-Za-z]/.test(masked[lt + 1])) {
75
+ let j = lt + 1;
76
+ while (j < len && /[A-Za-z0-9:-]/.test(masked[j]))
77
+ j++;
78
+ const tagName = masked.slice(lt + 1, j).toLowerCase();
79
+ j = scanTagAttributes(masked, j, wanted, out);
80
+ // Skip raw-text element content up to its closing tag.
81
+ if (RAW_TEXT_TAGS.has(tagName)) {
82
+ const closer = `</${tagName}`;
83
+ const idx = masked.toLowerCase().indexOf(closer, j);
84
+ j = idx === -1 ? len : idx;
85
+ }
86
+ i = j;
87
+ continue;
88
+ }
89
+ i = lt + 1;
90
+ }
91
+ return out;
92
+ }
93
+ const isTagWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === NUL;
94
+ /**
95
+ * Parse attributes from just past the tag name to just past `>`.
96
+ * Returns the offset after `>` (or EOF). Pushes matches into `out`.
97
+ */
98
+ function scanTagAttributes(masked, i, wanted, out) {
99
+ const len = masked.length;
100
+ while (i < len) {
101
+ while (i < len && isTagWs(masked[i]))
102
+ i++;
103
+ if (i >= len)
104
+ return len;
105
+ const c = masked[i];
106
+ if (c === '>')
107
+ return i + 1;
108
+ if (c === '/') {
109
+ i++;
110
+ continue;
111
+ }
112
+ // Attribute name.
113
+ const nameStart = i;
114
+ while (i < len && !isTagWs(masked[i]) && masked[i] !== '=' && masked[i] !== '>' && masked[i] !== '/')
115
+ i++;
116
+ const name = masked.slice(nameStart, i);
117
+ if (name.length === 0) {
118
+ i++;
119
+ continue;
120
+ }
121
+ while (i < len && isTagWs(masked[i]))
122
+ i++;
123
+ if (masked[i] !== '=')
124
+ continue; // boolean attribute
125
+ i++;
126
+ while (i < len && isTagWs(masked[i]))
127
+ i++;
128
+ const q = masked[i];
129
+ if (q === '"' || q === "'") {
130
+ const valueStart = i + 1;
131
+ const close = masked.indexOf(q, valueStart);
132
+ const valueEnd = close === -1 ? len : close;
133
+ if (wanted.has(name.toLowerCase())) {
134
+ out.push({ name, valueStart, valueEnd });
135
+ }
136
+ i = valueEnd + 1;
137
+ }
138
+ else {
139
+ // Unquoted value — read it but never rewrite (too risky to widen).
140
+ while (i < len && !isTagWs(masked[i]) && masked[i] !== '>')
141
+ i++;
142
+ }
143
+ }
144
+ return len;
145
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Public API for `@runtimestudio/tailwind-sort-php`.
3
+ *
4
+ * Tailwind CSS class sorting for mixed PHP/HTML templates. The core is dependency-free;
5
+ * the official sorting engine is wired in via `createTailwindSortFn()` or any custom `SortFn`.
6
+ *
7
+ * @packageDocumentation
8
+ * @see cli.ts - command-line interface built on this API.
9
+ */
10
+ export { transform, type SortFn, type TransformOptions } from './transform.ts';
11
+ export { findIslands, type Island, type IslandOptions } from './islands.ts';
12
+ export { maskIslands, findClassAttributes, type ClassAttr, type HtmlScanOptions } from './html.ts';
13
+ export { createTailwindSortFn, type SorterOptions } from './sorter.ts';
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Public API for `@runtimestudio/tailwind-sort-php`.
3
+ *
4
+ * Tailwind CSS class sorting for mixed PHP/HTML templates. The core is dependency-free;
5
+ * the official sorting engine is wired in via `createTailwindSortFn()` or any custom `SortFn`.
6
+ *
7
+ * @packageDocumentation
8
+ * @see cli.ts - command-line interface built on this API.
9
+ */
10
+ export { transform } from "./transform.js";
11
+ export { findIslands } from "./islands.js";
12
+ export { maskIslands, findClassAttributes } from "./html.js";
13
+ export { createTailwindSortFn } from "./sorter.js";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * PHP island detection — the first pass of the two-pass lexer.
3
+ *
4
+ * Scans the raw template source and returns the byte ranges of all PHP "islands"
5
+ * (`<?php ... ?>`, `<?= ... ?>`, and optionally short `<? ... ?>`).
6
+ *
7
+ * The closing `?>` is found with a real PHP string/comment lexer, so a `?>` inside a string literal, heredoc/nowdoc,
8
+ * or block comment does NOT close the island — whereas one inside a `//` or `#` line comment closes it (PHP quirk).
9
+ *
10
+ * @see html.ts - second pass; consumes islands via `maskIslands()`.
11
+ */
12
+ /**
13
+ * A contiguous region of PHP code within a mixed template source.
14
+ */
15
+ export interface Island {
16
+ /**
17
+ * Inclusive start offset of `<?`.
18
+ */
19
+ start: number;
20
+ /**
21
+ * Exclusive end offset (just past `?>`, or EOF).
22
+ */
23
+ end: number;
24
+ }
25
+ /**
26
+ * Options controlling PHP open-tag recognition.
27
+ */
28
+ export interface IslandOptions {
29
+ /**
30
+ * Treat bare `<?` as a PHP open tag (short_open_tag).
31
+ * Default true, with a guard so `<?xml` is never treated as PHP.
32
+ */
33
+ shortOpenTags?: boolean;
34
+ }
35
+ /**
36
+ * Find every PHP island in a mixed PHP/HTML template source.
37
+ *
38
+ * Islands are returned in document order and never overlap.
39
+ * All offsets index into the original (unmodified) source string.
40
+ *
41
+ * @param src Raw template source (mixed PHP/HTML).
42
+ * @param opts Open-tag recognition options.
43
+ * @returns Ordered, non-overlapping island ranges.
44
+ *
45
+ * @example
46
+ * const islands = findIslands('<p><?= $x ?></p>');
47
+ * // [{ start: 3, end: 12 }]
48
+ */
49
+ export declare function findIslands(src: string, opts?: IslandOptions): Island[];
@@ -0,0 +1,206 @@
1
+ /**
2
+ * PHP island detection — the first pass of the two-pass lexer.
3
+ *
4
+ * Scans the raw template source and returns the byte ranges of all PHP "islands"
5
+ * (`<?php ... ?>`, `<?= ... ?>`, and optionally short `<? ... ?>`).
6
+ *
7
+ * The closing `?>` is found with a real PHP string/comment lexer, so a `?>` inside a string literal, heredoc/nowdoc,
8
+ * or block comment does NOT close the island — whereas one inside a `//` or `#` line comment closes it (PHP quirk).
9
+ *
10
+ * @see html.ts - second pass; consumes islands via `maskIslands()`.
11
+ */
12
+ const isIdentStart = (c) => /[A-Za-z_\u0080-\uffff]/.test(c);
13
+ const isIdent = (c) => /[A-Za-z0-9_\u0080-\uffff]/.test(c);
14
+ /**
15
+ * Find every PHP island in a mixed PHP/HTML template source.
16
+ *
17
+ * Islands are returned in document order and never overlap.
18
+ * All offsets index into the original (unmodified) source string.
19
+ *
20
+ * @param src Raw template source (mixed PHP/HTML).
21
+ * @param opts Open-tag recognition options.
22
+ * @returns Ordered, non-overlapping island ranges.
23
+ *
24
+ * @example
25
+ * const islands = findIslands('<p><?= $x ?></p>');
26
+ * // [{ start: 3, end: 12 }]
27
+ */
28
+ export function findIslands(src, opts = {}) {
29
+ const shortTags = opts.shortOpenTags !== false;
30
+ const islands = [];
31
+ const len = src.length;
32
+ let i = 0;
33
+ while (i < len) {
34
+ const open = src.indexOf('<?', i);
35
+ if (open === -1)
36
+ break;
37
+ // Classify the open tag.
38
+ const after = src.slice(open + 2, open + 6).toLowerCase();
39
+ let bodyStart;
40
+ if (after.startsWith('php') && (open + 5 >= len || !isIdent(src[open + 5]))) {
41
+ bodyStart = open + 5;
42
+ }
43
+ else if (src[open + 2] === '=') {
44
+ bodyStart = open + 3;
45
+ }
46
+ else if (shortTags && !after.startsWith('xml')) {
47
+ bodyStart = open + 2;
48
+ }
49
+ else {
50
+ i = open + 2; // not a PHP tag (e.g. `<?xml`) — keep scanning
51
+ continue;
52
+ }
53
+ const end = scanPhpBody(src, bodyStart);
54
+ islands.push({ start: open, end });
55
+ i = end;
56
+ }
57
+ return islands;
58
+ }
59
+ /**
60
+ * Scan PHP code starting at `i`, returning the offset just past the closing `?>`,
61
+ * or `src.length` if the file ends in PHP mode.
62
+ */
63
+ function scanPhpBody(src, i) {
64
+ const len = src.length;
65
+ while (i < len) {
66
+ const c = src[i];
67
+ // Possible close tag.
68
+ if (c === '?' && src[i + 1] === '>')
69
+ return i + 2;
70
+ // Single-quoted string.
71
+ if (c === "'") {
72
+ i = scanQuoted(src, i + 1, "'");
73
+ continue;
74
+ }
75
+ // Double-quoted string.
76
+ // Limitation: nested double quotes in complex `{$a["k"]}` interpolation can desync the lexer (documented).
77
+ if (c === '"') {
78
+ i = scanQuoted(src, i + 1, '"');
79
+ continue;
80
+ }
81
+ // Backtick (shell exec) string — same escaping rules.
82
+ if (c === '`') {
83
+ i = scanQuoted(src, i + 1, '`');
84
+ continue;
85
+ }
86
+ // Comments.
87
+ if (c === '/' && src[i + 1] === '/') {
88
+ i = scanLineComment(src, i + 2);
89
+ if (src.startsWith('?>', i))
90
+ return i + 2;
91
+ continue;
92
+ }
93
+ if (c === '#') {
94
+ // PHP 8 attribute `#[...]` is not a comment.
95
+ if (src[i + 1] === '[') {
96
+ i += 2;
97
+ continue;
98
+ }
99
+ i = scanLineComment(src, i + 1);
100
+ if (src.startsWith('?>', i))
101
+ return i + 2;
102
+ continue;
103
+ }
104
+ if (c === '/' && src[i + 1] === '*') {
105
+ const close = src.indexOf('*/', i + 2);
106
+ i = close === -1 ? len : close + 2;
107
+ continue;
108
+ }
109
+ // Heredoc / nowdoc.
110
+ if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
111
+ const here = scanHeredoc(src, i + 3);
112
+ if (here !== -1) {
113
+ i = here;
114
+ continue;
115
+ }
116
+ }
117
+ i++;
118
+ }
119
+ return len; // file ends while still in PHP mode
120
+ }
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,31 @@
1
+ /**
2
+ * Adapter: official Tailwind sorter → core `SortFn`.
3
+ *
4
+ * Kept separate from the core so the lexer/transformer stay dependency-free and testable with any injected sorter.
5
+ *
6
+ * @see transform.ts - consumes the `SortFn` produced here.
7
+ */
8
+ import type { SortFn } from './transform.ts';
9
+ /**
10
+ * Options for constructing the official Tailwind sorter.
11
+ */
12
+ export interface SorterOptions {
13
+ /**
14
+ * Tailwind v4 CSS entry point (`@import "tailwindcss"`, `@theme`, etc.).
15
+ */
16
+ stylesheet: string;
17
+ /**
18
+ * Base directory for resolving relative paths. Default: `cwd`.
19
+ */
20
+ base?: string;
21
+ }
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.
25
+ *
26
+ * Requires `prettier-plugin-tailwindcss` >= 0.8 (the `/sorter` entrypoint).
27
+ *
28
+ * @param opts Stylesheet and path resolution options.
29
+ * @returns A synchronous `SortFn` for use with `transform()`.
30
+ */
31
+ export declare function createTailwindSortFn(opts: SorterOptions): Promise<SortFn>;
package/dist/sorter.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Adapter: official Tailwind sorter → core `SortFn`.
3
+ *
4
+ * Kept separate from the core so the lexer/transformer stay dependency-free and testable with any injected sorter.
5
+ *
6
+ * @see transform.ts - consumes the `SortFn` produced here.
7
+ */
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.
11
+ *
12
+ * Requires `prettier-plugin-tailwindcss` >= 0.8 (the `/sorter` entrypoint).
13
+ *
14
+ * @param opts Stylesheet and path resolution options.
15
+ * @returns A synchronous `SortFn` for use with `transform()`.
16
+ */
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).
20
+ const { createSorter } = await import('prettier-plugin-tailwindcss/sorter');
21
+ const sorter = await createSorter({
22
+ base: opts.base ?? process.cwd(),
23
+ stylesheetPath: opts.stylesheet,
24
+ });
25
+ return (classes) => sorter.sortClassLists([classes])[0];
26
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Core transform: given template source and a sort function,
3
+ * rewrite all class attribute values with sorted Tailwind classes.
4
+ *
5
+ * Island-aware rules inside an attribute value:
6
+ * - PHP islands are opaque atoms that never move.
7
+ * - Static text is split into runs between islands; each run's classes are sorted independently
8
+ * (mirrors how the official Prettier plugin treats `${}` interpolations in template literals).
9
+ * - A token with no whitespace between it and an adjacent island is a *fragment* of a dynamically
10
+ * built class (`btn-<?= $v ?>`); it is pinned in place and excluded from sorting.
11
+ * - Whitespace adjacent to islands is preserved as a single space — never removed
12
+ * (removal would concatenate classes at render time) and never invented
13
+ * (insertion would split an intentional fragment).
14
+ *
15
+ * @see islands.ts - pass 1 (PHP island detection).
16
+ * @see html.ts - pass 2 (attribute location).
17
+ * @see sorter.ts - adapter producing the injected `SortFn`.
18
+ */
19
+ import { type IslandOptions } from './islands.ts';
20
+ import { type HtmlScanOptions } from './html.ts';
21
+ /**
22
+ * Sorting strategy injected by the caller. Receives the class tokens of a single static run;
23
+ * returns them in sorted order. Must be pure and synchronous; must not add or remove tokens.
24
+ */
25
+ export type SortFn = (classes: string[]) => string[];
26
+ /**
27
+ * Combined options for both lexer passes.
28
+ */
29
+ export interface TransformOptions extends IslandOptions, HtmlScanOptions {
30
+ }
31
+ /**
32
+ * Rewrite all class attribute values in the template source with sorted classes.
33
+ * Everything outside class attribute values is byte-identical in the result; the function is idempotent.
34
+ *
35
+ * @param src Raw template source (mixed PHP/HTML).
36
+ * @param sortFn Injected sorting strategy (see `createTailwindSortFn()`).
37
+ * @param opts Lexer options.
38
+ * @returns The rewritten source.
39
+ *
40
+ * @example
41
+ * transform('<div class="z-10 mt-4 <?= $x ?> b a">', sortFn);
42
+ * // '<div class="mt-4 z-10 <?= $x ?> a b">'
43
+ */
44
+ export declare function transform(src: string, sortFn: SortFn, opts?: TransformOptions): string;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Core transform: given template source and a sort function,
3
+ * rewrite all class attribute values with sorted Tailwind classes.
4
+ *
5
+ * Island-aware rules inside an attribute value:
6
+ * - PHP islands are opaque atoms that never move.
7
+ * - Static text is split into runs between islands; each run's classes are sorted independently
8
+ * (mirrors how the official Prettier plugin treats `${}` interpolations in template literals).
9
+ * - A token with no whitespace between it and an adjacent island is a *fragment* of a dynamically
10
+ * built class (`btn-<?= $v ?>`); it is pinned in place and excluded from sorting.
11
+ * - Whitespace adjacent to islands is preserved as a single space — never removed
12
+ * (removal would concatenate classes at render time) and never invented
13
+ * (insertion would split an intentional fragment).
14
+ *
15
+ * @see islands.ts - pass 1 (PHP island detection).
16
+ * @see html.ts - pass 2 (attribute location).
17
+ * @see sorter.ts - adapter producing the injected `SortFn`.
18
+ */
19
+ import { findIslands } from "./islands.js";
20
+ import { maskIslands, findClassAttributes } from "./html.js";
21
+ /**
22
+ * Rewrite all class attribute values in the template source with sorted classes.
23
+ * Everything outside class attribute values is byte-identical in the result; the function is idempotent.
24
+ *
25
+ * @param src Raw template source (mixed PHP/HTML).
26
+ * @param sortFn Injected sorting strategy (see `createTailwindSortFn()`).
27
+ * @param opts Lexer options.
28
+ * @returns The rewritten source.
29
+ *
30
+ * @example
31
+ * transform('<div class="z-10 mt-4 <?= $x ?> b a">', sortFn);
32
+ * // '<div class="mt-4 z-10 <?= $x ?> a b">'
33
+ */
34
+ export function transform(src, sortFn, opts = {}) {
35
+ const islands = findIslands(src, opts);
36
+ const masked = maskIslands(src, islands);
37
+ 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];
42
+ const original = src.slice(valueStart, valueEnd);
43
+ const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
44
+ const rewritten = rewriteValue(original, valueStart, inner, sortFn);
45
+ if (rewritten !== original) {
46
+ out = out.slice(0, valueStart) + rewritten + out.slice(valueEnd);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ function rewriteValue(value, base, islands, sortFn) {
52
+ // Build alternating static/island parts.
53
+ const parts = [];
54
+ let pos = 0;
55
+ for (const isl of islands) {
56
+ const s = isl.start - base;
57
+ const e = isl.end - base;
58
+ parts.push({ type: 'static', text: value.slice(pos, s) });
59
+ parts.push({ type: 'island', text: value.slice(s, e) });
60
+ pos = e;
61
+ }
62
+ parts.push({ type: 'static', text: value.slice(pos) });
63
+ let out = '';
64
+ for (let p = 0; p < parts.length; p++) {
65
+ const part = parts[p];
66
+ if (part.type === 'island') {
67
+ out += part.text;
68
+ continue;
69
+ }
70
+ const prevIsIsland = p > 0;
71
+ const nextIsIsland = p < parts.length - 1;
72
+ const t = part.text;
73
+ const hasLeadingWs = /^\s/.test(t);
74
+ const hasTrailingWs = /\s$/.test(t);
75
+ const tokens = t.split(/\s+/).filter(Boolean);
76
+ // Whitespace-only run between islands → preserve a single space.
77
+ if (tokens.length === 0) {
78
+ if (t.length > 0 && prevIsIsland && nextIsIsland)
79
+ out += ' ';
80
+ continue;
81
+ }
82
+ const pinStart = prevIsIsland && !hasLeadingWs;
83
+ const pinEnd = nextIsIsland && !hasTrailingWs;
84
+ let head = [];
85
+ let tail = [];
86
+ let middle;
87
+ if (pinStart && pinEnd && tokens.length === 1) {
88
+ // Single fragment glued to islands on both sides.
89
+ middle = [];
90
+ head = [tokens[0]];
91
+ }
92
+ else {
93
+ const from = pinStart ? 1 : 0;
94
+ const to = pinEnd ? tokens.length - 1 : tokens.length;
95
+ if (pinStart)
96
+ head = [tokens[0]];
97
+ if (pinEnd)
98
+ tail = [tokens[tokens.length - 1]];
99
+ middle = tokens.slice(from, to);
100
+ }
101
+ const sorted = middle.length > 1 ? sortFn(middle) : middle;
102
+ const joined = [...head, ...sorted, ...tail].join(' ');
103
+ const prefix = prevIsIsland && hasLeadingWs ? ' ' : '';
104
+ const suffix = nextIsIsland && hasTrailingWs ? ' ' : '';
105
+ out += prefix + joined + suffix;
106
+ }
107
+ return out;
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtimestudio/tailwind-sort-php",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Tailwind CSS class sorting for mixed PHP/HTML templates (WordPress-friendly)",
5
5
  "keywords": [
6
6
  "class-sorting",
@@ -15,27 +15,34 @@
15
15
  "license": "MIT",
16
16
  "author": "Greg Sevastos <greg@runtimestudio.com.au> (https://runtimestudio.com.au)",
17
17
  "files": [
18
+ "dist",
18
19
  "src"
19
20
  ],
20
- "main": "src/index.ts",
21
+ "main": "dist/index.js",
22
+ "types": "dist/index.d.ts",
21
23
  "type": "module",
22
24
  "bin": {
23
- "tailwind-sort-php": "src/cli.ts"
25
+ "tailwind-sort-php": "dist/cli.js"
24
26
  },
25
27
  "repository": {
26
28
  "type": "git",
27
29
  "url": "git+https://github.com/runtime-studio-au/tailwind-sort-php.git"
28
30
  },
29
31
  "scripts": {
32
+ "build": "tsc -p tsconfig.build.json",
30
33
  "format": "prettier --write .",
31
34
  "format:check": "prettier --check .",
35
+ "prepublishOnly": "npm test && npm run build",
32
36
  "sort": "bun run src/cli.ts",
33
37
  "sort:check": "bun run src/cli.ts --check",
34
38
  "test": "node --test \"test/*.test.ts\""
35
39
  },
36
40
  "devDependencies": {
37
41
  "@types/bun": "latest",
38
- "prettier": "^3.8"
42
+ "prettier": "^3.8",
43
+ "prettier-plugin-tailwindcss": "^0.8",
44
+ "tailwindcss": "^4",
45
+ "typescript": ">=5.7"
39
46
  },
40
47
  "peerDependencies": {
41
48
  "prettier": ">=3",