@runtimestudio/tailwind-sort-php 0.1.0 → 0.2.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 +78 -7
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +167 -0
- package/dist/html.d.ts +59 -0
- package/dist/html.js +145 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/init.d.ts +12 -0
- package/dist/init.js +171 -0
- package/dist/islands.d.ts +49 -0
- package/dist/islands.js +206 -0
- package/dist/sorter.d.ts +31 -0
- package/dist/sorter.js +26 -0
- package/dist/transform.d.ts +44 -0
- package/dist/transform.js +108 -0
- package/package.json +12 -5
- package/src/cli.ts +7 -2
- package/src/init.ts +188 -0
package/dist/init.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-sort-php init
|
|
3
|
+
*
|
|
4
|
+
* Installs the pre-commit hook: writes `.githooks/pre-commit` and points `core.hooksPath` at it.
|
|
5
|
+
* No-clobber unless `--force`; `--fix` installs the auto-fixing variant; `--dry-run` previews.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
const HOOKS_DIR = '.githooks';
|
|
11
|
+
const HOOK_PATH = `${HOOKS_DIR}/pre-commit`;
|
|
12
|
+
/**
|
|
13
|
+
* Check-and-fail hook (default): names the unsorted files and blocks the commit; never writes.
|
|
14
|
+
*/
|
|
15
|
+
const HOOK_CHECK = `#!/bin/sh
|
|
16
|
+
# Block commits with unsorted Tailwind classes in staged PHP files.
|
|
17
|
+
# Installed by \`tailwind-sort-php init\`. Note: checks working-tree file
|
|
18
|
+
# contents, so partial staging (git add -p) can mis-report; see README.
|
|
19
|
+
sorter=./node_modules/.bin/tailwind-sort-php
|
|
20
|
+
[ -x "$sorter" ] || exit 0
|
|
21
|
+
git diff --cached --name-only --diff-filter=ACMR -- '*.php' | grep -q . || exit 0
|
|
22
|
+
if git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter" --check; then
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
echo >&2
|
|
26
|
+
echo "Unsorted Tailwind classes in staged PHP (see above)." >&2
|
|
27
|
+
echo "Fix with: npx tailwind-sort-php (or: bunx tailwind-sort-php), then re-stage." >&2
|
|
28
|
+
exit 1
|
|
29
|
+
`;
|
|
30
|
+
/**
|
|
31
|
+
* Auto-fix hook (--fix): sorts the staged files in place, then blocks the commit for review.
|
|
32
|
+
*/
|
|
33
|
+
const HOOK_FIX = `#!/bin/sh
|
|
34
|
+
# Sort Tailwind classes in staged PHP files, then abort the commit so the
|
|
35
|
+
# changes can be reviewed and re-staged. Rewrites working-tree files.
|
|
36
|
+
# Installed by \`tailwind-sort-php init --fix\`. Note: with partial staging
|
|
37
|
+
# (git add -p), re-staging can pull in unrelated unstaged hunks; see README.
|
|
38
|
+
sorter=./node_modules/.bin/tailwind-sort-php
|
|
39
|
+
[ -x "$sorter" ] || exit 0
|
|
40
|
+
git diff --cached --name-only --diff-filter=ACMR -- '*.php' | grep -q . || exit 0
|
|
41
|
+
if git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter" --check >/dev/null 2>&1; then
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter"
|
|
45
|
+
echo >&2
|
|
46
|
+
echo "Sorted Tailwind classes in staged PHP (see above)." >&2
|
|
47
|
+
echo "Review the changes, re-stage, and commit again." >&2
|
|
48
|
+
exit 1
|
|
49
|
+
`;
|
|
50
|
+
/**
|
|
51
|
+
* Parse `init` subcommand arguments.
|
|
52
|
+
*
|
|
53
|
+
* @param argv Arguments after the `init` subcommand name.
|
|
54
|
+
* @returns Parsed flags with defaults applied.
|
|
55
|
+
*/
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const cli = { fix: false, force: false, dryRun: false };
|
|
58
|
+
for (const a of argv) {
|
|
59
|
+
if (a === '--fix')
|
|
60
|
+
cli.fix = true;
|
|
61
|
+
else if (a === '--force')
|
|
62
|
+
cli.force = true;
|
|
63
|
+
else if (a === '--dry-run')
|
|
64
|
+
cli.dryRun = true;
|
|
65
|
+
else {
|
|
66
|
+
console.error(`Unknown init option: ${a}`);
|
|
67
|
+
process.exit(2);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return cli;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Run a git command and capture its output.
|
|
74
|
+
*
|
|
75
|
+
* @param args Arguments passed to `git`.
|
|
76
|
+
* @returns Trimmed stdout, or `null` when git exits non-zero (unset config, not a repo, …).
|
|
77
|
+
*/
|
|
78
|
+
function git(args) {
|
|
79
|
+
try {
|
|
80
|
+
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Print an error to stderr and exit with status 1.
|
|
88
|
+
*
|
|
89
|
+
* @param message Error text explaining why init refused to proceed.
|
|
90
|
+
*/
|
|
91
|
+
function fail(message) {
|
|
92
|
+
console.error(message);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Run the `init` subcommand: install the pre-commit hook and point `core.hooksPath` at it.
|
|
97
|
+
*
|
|
98
|
+
* @param argv Arguments after the `init` subcommand name.
|
|
99
|
+
*/
|
|
100
|
+
export async function runInit(argv) {
|
|
101
|
+
const cli = parseArgs(argv);
|
|
102
|
+
const hookBody = cli.fix ? HOOK_FIX : HOOK_CHECK;
|
|
103
|
+
const variant = cli.fix ? 'fix' : 'check';
|
|
104
|
+
// Anchor everything at the repository root: a relative `core.hooksPath` resolves there,
|
|
105
|
+
// and the hook's `./node_modules/...` path assumes hooks run from it (they do, for pre-commit).
|
|
106
|
+
const top = git(['rev-parse', '--show-toplevel']);
|
|
107
|
+
if (top === null)
|
|
108
|
+
fail('Not a git repository (or a bare one) — run init from inside a working tree.');
|
|
109
|
+
const gitDir = git(['rev-parse', '--absolute-git-dir']);
|
|
110
|
+
const hookAbs = join(top, HOOK_PATH);
|
|
111
|
+
// Decide whether `core.hooksPath` needs to change. Repointing it makes git ignore `.git/hooks` entirely,
|
|
112
|
+
// so refuse to silently disable hooks that already live there.
|
|
113
|
+
const hooksPath = git(['config', '--get', 'core.hooksPath']);
|
|
114
|
+
let setConfig = false;
|
|
115
|
+
if (hooksPath === null) {
|
|
116
|
+
const live = (await readdir(join(gitDir, 'hooks')).catch(() => [])).filter((f) => !f.endsWith('.sample'));
|
|
117
|
+
if (live.length > 0 && !cli.force) {
|
|
118
|
+
fail(`Found existing hook(s) in .git/hooks: ${live.join(', ')}.\n` +
|
|
119
|
+
`Setting core.hooksPath would disable them. Move them into ${HOOKS_DIR}/ first, ` +
|
|
120
|
+
'or re-run with --force to proceed anyway.');
|
|
121
|
+
}
|
|
122
|
+
setConfig = true;
|
|
123
|
+
}
|
|
124
|
+
else if (hooksPath !== HOOKS_DIR) {
|
|
125
|
+
if (!cli.force) {
|
|
126
|
+
fail(`core.hooksPath is already set to "${hooksPath}" — add the hook there yourself, or re-run ` +
|
|
127
|
+
`with --force to repoint it to ${HOOKS_DIR}. Hook body:\n\n${hookBody}`);
|
|
128
|
+
}
|
|
129
|
+
setConfig = true;
|
|
130
|
+
}
|
|
131
|
+
// Decide whether the hook file needs writing; overwriting a differing hook needs --force.
|
|
132
|
+
const current = await readFile(hookAbs, 'utf8').catch(() => null);
|
|
133
|
+
let writeHook = current === null;
|
|
134
|
+
let repairMode = false;
|
|
135
|
+
if (current !== null && current !== hookBody) {
|
|
136
|
+
if (!cli.force) {
|
|
137
|
+
const installed = current === HOOK_CHECK ? 'the check variant' : current === HOOK_FIX ? 'the --fix variant' : 'a custom hook';
|
|
138
|
+
fail(`${HOOK_PATH} already exists and differs (${installed} is installed). Re-run with --force to overwrite.`);
|
|
139
|
+
}
|
|
140
|
+
writeHook = true;
|
|
141
|
+
}
|
|
142
|
+
if (current === hookBody) {
|
|
143
|
+
// Content is already right; still repair a missing executable bit, or git silently skips the hook.
|
|
144
|
+
repairMode = ((await stat(hookAbs)).mode & 0o111) === 0;
|
|
145
|
+
}
|
|
146
|
+
const done = [];
|
|
147
|
+
if (writeHook) {
|
|
148
|
+
if (!cli.dryRun) {
|
|
149
|
+
await mkdir(join(top, HOOKS_DIR), { recursive: true });
|
|
150
|
+
await writeFile(hookAbs, hookBody);
|
|
151
|
+
await chmod(hookAbs, 0o755);
|
|
152
|
+
}
|
|
153
|
+
done.push(cli.dryRun ? `would install ${HOOK_PATH} (${variant} variant)` : `installed ${HOOK_PATH} (${variant} variant)`);
|
|
154
|
+
}
|
|
155
|
+
if (repairMode) {
|
|
156
|
+
if (!cli.dryRun)
|
|
157
|
+
await chmod(hookAbs, 0o755);
|
|
158
|
+
done.push(cli.dryRun ? `would make ${HOOK_PATH} executable` : `made ${HOOK_PATH} executable`);
|
|
159
|
+
}
|
|
160
|
+
if (setConfig) {
|
|
161
|
+
if (!cli.dryRun)
|
|
162
|
+
git(['config', 'core.hooksPath', HOOKS_DIR]);
|
|
163
|
+
done.push(cli.dryRun ? `would set core.hooksPath = ${HOOKS_DIR}` : `set core.hooksPath = ${HOOKS_DIR}`);
|
|
164
|
+
}
|
|
165
|
+
if (done.length === 0) {
|
|
166
|
+
console.log(`Nothing to do — ${HOOK_PATH} (${variant} variant) is already installed.`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
for (const line of done)
|
|
170
|
+
console.log(line);
|
|
171
|
+
}
|
|
@@ -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[];
|
package/dist/islands.js
ADDED
|
@@ -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
|
+
}
|
package/dist/sorter.d.ts
ADDED
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtimestudio/tailwind-sort-php",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Tailwind CSS
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Tailwind CSS Class Sorter for PHP",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"class-sorting",
|
|
7
7
|
"formatter",
|
|
@@ -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": "
|
|
21
|
+
"main": "dist/index.js",
|
|
22
|
+
"types": "dist/index.d.ts",
|
|
21
23
|
"type": "module",
|
|
22
24
|
"bin": {
|
|
23
|
-
"tailwind-sort-php": "
|
|
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",
|