Skip to content

feat: tailwindClassOrder — priority-based class bucketing#5

Merged
zipper merged 9 commits into
mainfrom
feat/class-order-buckets
Apr 17, 2026
Merged

feat: tailwindClassOrder — priority-based class bucketing#5
zipper merged 9 commits into
mainfrom
feat/class-order-buckets

Conversation

@zipper
Copy link
Copy Markdown
Owner

@zipper zipper commented Apr 17, 2026

Summary

  • Adds tailwindClassOrder option — configurable class ordering through buckets (unknown / tailwind / regex patterns).
  • Priority-based matching: explicit {pattern} buckets always win over "unknown" / "tailwind" catchalls, regardless of position. Config bucket order controls only where each group appears in the output.
  • Also contains two n:class parser fixes discovered while testing on a real Latte project.

Config shape

Value is a string — path to JS/JSON file, or JSON-encoded array. Direct inline array in .prettierrc doesn't work because Prettier CLI collapses non-array:true option arrays to their last element.

// tailwind-class-order.js
module.exports = [
  [
    'unknown',
    { pattern: '^icon(?:--|$)' },
    'tailwind',
    { pattern: '^ajax' },
    { pattern: '^js-' }
  ],
  { unspecified: 'top' }
]

When the option is unset, behaviour is equivalent to the implicit default [["unknown", "tailwind"], {"unspecified":"top"}] — same output as before.

Use cases

  • JS hooks at the end: { pattern: '^js-' } as last bucket
  • BEM grouped with base: { pattern: '^icon(?:--|$)' }
  • Component-like utilities up front: { pattern: '^h[1-6]$' }
  • Custom class frameworks kept separate from generic Tailwind utilities

Breaking changes (relative to unreleased state of the feature)

  • Option value is string-only. Inline arrays in .prettierrc are not supported — use an external .js/.json file or a JSON-encoded string. (API callers passing arrays to prettier.format still work.)

Commits

  1. refactor — extract compareTailwindEntries shared by sortClassList and sortGroup
  2. feat — new src/class-order.ts (types, parser, resolver, applyBuckets)
  3. feat — wire tailwindClassOrder through options / types / loader / sort pipeline
  4. test — integration tests for bucket algorithm + property-order interaction + BEM + n:class parity
  5. docs — README + docs/options.md
  6. feat! — priority-based matching, drop exception parameter (string-only config)
  7. fix — don't split conditional branches that contain PHP concatenation ('foo bar' . $x->m() used to be shredded into tokens and corrupted)
  8. fix — preserve newlines between sortable tokens in normalize-barriers mode (previously collapsed multi-line n:class layout to a single line)

Test plan

  • Unit tests for parser / resolver / applyBuckets
  • Integration tests — priority matching, BEM, greedy-style pattern stealing, unspecified placement, n:class parity, property-order interaction
  • Full suite: 349/349 passed
  • Lint clean (--max-warnings=0), build OK (CJS + ESM + DTS)
  • Verified on a real project (fokus-optik) — JSON and JS config, priority-based matching with ^ajax / ^js- / ^icon(?:--|$) / ^h[1-6]$, default context preserves legacy behaviour
  • Regression tests for both n:class fixes

zipper added 9 commits April 17, 2026 09:55
Extract duplicated sort callback logic from sortClassList and sortGroup
into shared compareTailwindEntries function. No behavior change.

Prepares ground for upcoming bucket-based class ordering (tailwindClassOrder).
Introduce src/class-order.ts with greedy bucket-by-bucket algorithm for
configurable class ordering. Pattern and unknown buckets preserve input
order; only the tailwind bucket sorts (using a caller-supplied comparator).

Config parser accepts flat array or tuple [items, {unspecified}] form,
plus an alias 'tailwindcss' for 'tailwind'. Resolver dispatches on value
type (array | string path | undefined) with jiti-based loading identical
to property-order loader.

Not wired into sortClassList/sortGroup yet — that's Phase C.
Sorting now always runs through applyBuckets from src/class-order.ts.
When the option is not set, a default context ({unknown, tailwind},
unspecified:'top') replicates previous unknown-first + tailwind-by-bigint
behavior — single code-path, no legacy branch.

- src/options.ts: new SupportOption 'tailwindClassOrder' (type:string,
  exception:Array.isArray). The exception passthrough lets users put the
  config array directly in .prettierrc.* without JSON-string escaping,
  while string paths remain supported for external JS/JSON configs.
- src/types.ts: LatteOptions.tailwindClassOrder; TailwindContext.classOrder
  (required — always present via defaultClassOrderContext()).
- src/tailwind.ts: loadTailwindContext accepts classOrderRaw; cache key
  now includes JSON.stringify(classOrderRaw ?? '') to prevent cross-file
  context reuse with different configs.
- src/index.ts: propagate options.tailwindClassOrder into loader.
- src/sorting.ts + src/nclass.ts: sortClassList and sortGroup delegate to
  applyBuckets. Dedup runs AFTER bucketing (first-occurrence-wins).
  compareTailwindEntries is called only inside the 'tailwind' bucket where
  non-null bigint is guaranteed.

Placeholder strings ('...' / '…') now fall into the unknown bucket (stable
input order) instead of being forced to the end. Plan explicitly permits
this — placeholders don't occur in Latte templates (dynamic values go
through n:class token barriers), and the removed special-case matched
prettier-plugin-tailwindcss behavior that isn't meaningful here.
16 test cases across 7 categories: property order interaction, realistic
config, n:class parity, greedy stealing, unspecified top/bottom, default
context equivalence, BEM scenario.

Also documents the 'unknown before pattern' edge case explicitly — if
unknown bucket comes first, it consumes all null-bigint classes before
a later regex pattern gets a chance to match them.
Adds options table row in README and full reference with use-case
examples (JS hooks last, BEM grouping, component-like utilities first)
and notes on interaction with tailwindPropertyOrder.
BREAKING CHANGE: Rework matching semantics and config value type.

**Matching model — priority-based (two phases):**

1. Assign each class to a bucket:
   - Explicit {pattern} buckets always win, regardless of position in config.
   - If no pattern matched: first 'tailwind' (non-null bigint) or first
     'unknown' (null bigint). Otherwise unspecified.
2. Emit in config order. 'tailwind' bucket sorts; patterns + 'unknown' stay
   in input order. Unspecified leftovers go front/back per 'unspecified'.

This fixes a real-world problem: when a class like 'ajax' is a known TW
utility (non-null bigint), the previous greedy algorithm would let
'tailwind' swallow it before the later {pattern:'^ajax'} bucket had a
chance to match. Priority-first matching removes that ordering trap —
patterns consistently win. Config bucket order now only controls where
each group appears in the output.

**Config value — string only:**

Prettier CLI silently collapses non-'array:true' option arrays to their
last element in .prettierrc, which made the 'exception: Array.isArray'
passthrough trick (spike-tested via API only) unreliable at the config
boundary. Drop it. Option is now type:'string' with two forms:
  - path to external JS/JSON file (recommended), or
  - JSON-encoded string starting with '['.

Direct array in options still works for API callers (prettier.format),
but is no longer the documented or primary path.

**Tests:** existing greedy-steal scenarios reworded to priority
semantics; added JSON-string resolver coverage and a fokus-optik-style
test where 'ajax' has a non-null bigint and must still land in the
{pattern:'^ajax'} bucket.
sortBranch used to fall through to a "bare multi-word" case that passed
anything with whitespace and no leading $ to sortClasses. A ternary
branch like "'foo bar' . \->m()" starts with apostrophe (so it failed
the strict quoted-string check — no closing apostrophe at the end) but
did contain whitespace, so it got split into class tokens and reordered,
corrupting the PHP syntax (the ". \->m()" suffix landed in the middle
of the class list, breaking the template).

Treat any branch that isn't a plain single-quoted string as atomic and
leave it untouched. Classes inside quoted fragments of a concatenation
could theoretically still be sorted separately, but that's not worth the
risk of corrupting the expression.

Regression test in tests/nclass.test.ts covers a realistic case from the
fokus-optik pdforms-ajax-spinner wrapper.
…arriers

Previously normalize-barriers collapsed every separator between two sortable
tokens to ', ' — including when the user had written each token on its own
line. That erased meaningful multi-line layout like:

  n:class="'product-list',
          ...
          'xs:gap-sm',
          'sm:[--max-column-count:3]',
          ..."

where each breakpoint lived on its own line. After sorting the two sortable
neighbours the newline between them vanished and they ended up glued on one
line.

Keep the behaviour intent (normalise horizontal whitespace around the
comma) but preserve the original newline + indentation when the separator
between two sortable tokens contains a \n. Single-line inputs still
collapse to a single ', ' separator.

Updates two whitespace-mode tests that asserted the old collapse behaviour,
adds a new test for the single-line normalisation branch, updates
docs/options.md.
@zipper zipper merged commit 789fcb9 into main Apr 17, 2026
1 check passed
@zipper zipper deleted the feat/class-order-buckets branch April 17, 2026 12:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant