Skip to content

Commit 911e6b2

Browse files
committed
wip
1 parent 6c8b9fe commit 911e6b2

19 files changed

Lines changed: 1399 additions & 0 deletions

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"replace": {
9898
"tempest/auth": "self.version",
9999
"tempest/cache": "self.version",
100+
"tempest/class-variance": "self.version",
100101
"tempest/clock": "self.version",
101102
"tempest/command-bus": "self.version",
102103
"tempest/console": "self.version",
@@ -138,6 +139,7 @@
138139
"psr-4": {
139140
"Tempest\\Auth\\": "packages/auth/src",
140141
"Tempest\\Cache\\": "packages/cache/src",
142+
"Tempest\\ClassVariance\\": "packages/class-variance/src",
141143
"Tempest\\Clock\\": "packages/clock/src",
142144
"Tempest\\CommandBus\\": "packages/command-bus/src",
143145
"Tempest\\Console\\": "packages/console/src",
@@ -171,6 +173,7 @@
171173
"Tempest\\Vite\\": "packages/vite/src"
172174
},
173175
"files": [
176+
"packages/class-variance/src/functions.php",
174177
"packages/clock/src/functions.php",
175178
"packages/command-bus/src/functions.php",
176179
"packages/container/src/functions.php",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "tempest/class-variance",
3+
"description": "The PHP framework that gets out of your way.",
4+
"require": {
5+
"php": "^8.5",
6+
"tempest/core": "3.x-dev",
7+
"tempest/support": "3.x-dev"
8+
},
9+
"autoload": {
10+
"psr-4": {
11+
"Tempest\\ClassVariance\\": "src"
12+
},
13+
"files": [
14+
"src/functions.php"
15+
]
16+
},
17+
"autoload-dev": {
18+
"psr-4": {
19+
"Tempest\\ClassVariance\\Tests\\": "tests"
20+
}
21+
},
22+
"license": "MIT",
23+
"minimum-stability": "dev"
24+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.4/phpunit.xsd"
5+
bootstrap="vendor/autoload.php"
6+
executionOrder="depends,defects"
7+
beStrictAboutOutputDuringTests="true"
8+
displayDetailsOnPhpunitDeprecations="true"
9+
failOnPhpunitDeprecation="false"
10+
failOnRisky="true"
11+
failOnWarning="true"
12+
>
13+
<testsuites>
14+
<testsuite name="Tempest Class Variance">
15+
<directory>tests</directory>
16+
</testsuite>
17+
</testsuites>
18+
<source restrictNotices="true" restrictWarnings="true" ignoreIndirectDeprecations="true">
19+
<include>
20+
<directory>src</directory>
21+
</include>
22+
</source>
23+
</phpunit>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\ClassVariance;
6+
7+
/**
8+
* Merges a flat list of CSS class strings, resolving conflicts
9+
* so that later classes override earlier ones within the same group.
10+
*/
11+
interface ClassMerger
12+
{
13+
/**
14+
* Merge the given class strings, returning a single deduplicated string.
15+
* Conflict resolution strategy is determined by the implementation.
16+
*/
17+
public function merge(string ...$classes): string;
18+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\ClassVariance;
6+
7+
/**
8+
* Immutable value object representing a parsed, flat list of CSS class tokens.
9+
*
10+
* Handles all input shapes that appear in variant definitions:
11+
* - Plain string → implicitly targets the 'base' slot only
12+
* - Indexed array of strings → flat class list, implicitly 'base'
13+
* - Associative array → slot-keyed map; extract by requested slot
14+
* - Bool → always empty (booleans are not class names)
15+
*
16+
* @internal
17+
*/
18+
final readonly class ClassNames
19+
{
20+
/** @param list<string> $items */
21+
private function __construct(
22+
private array $items = [],
23+
) {}
24+
25+
/**
26+
* Parse any variant value into a ClassNames instance for the given slot.
27+
*
28+
* A plain string or indexed array implicitly targets the 'base' slot.
29+
* Passing $slot = '' is the passthrough context (e.g. extra $props['class'])
30+
* and always emits the classes regardless of slot.
31+
*
32+
* @param string|array<array-key, mixed>|bool $input
33+
*/
34+
public static function of(string|array|bool $input, string $slot = ''): self
35+
{
36+
if (is_bool($input)) {
37+
return new self();
38+
}
39+
40+
if (is_string($input)) {
41+
// Plain string implicitly targets 'base'. Skip for any other explicit slot.
42+
if ($slot !== '' && $slot !== 'base') {
43+
return new self();
44+
}
45+
46+
return new self(self::tokenise($input));
47+
}
48+
49+
// Indexed (list) array — implicitly 'base', same rule as plain string.
50+
if (array_is_list($input)) {
51+
if ($slot !== '' && $slot !== 'base') {
52+
return new self();
53+
}
54+
55+
$items = [];
56+
foreach ($input as $entry) {
57+
if (is_string($entry)) {
58+
array_push($items, ...self::tokenise($entry));
59+
}
60+
}
61+
62+
return new self($items);
63+
}
64+
65+
// Associative (slot-keyed) array — extract the requested slot's value.
66+
$slotValue = $input[$slot] ?? null;
67+
68+
if ($slotValue === null || is_bool($slotValue)) {
69+
return new self();
70+
}
71+
72+
// Recurse with slot='' so the plain-string / list rules above apply
73+
// without the 'base'-only guard (we have already resolved the slot key).
74+
return self::of($slotValue, '');
75+
}
76+
77+
public static function empty(): self
78+
{
79+
return new self();
80+
}
81+
82+
public function concat(self $other): self
83+
{
84+
return new self([...$this->items, ...$other->items]);
85+
}
86+
87+
/** @return list<string> */
88+
public function toArray(): array
89+
{
90+
return array_values(array_unique(
91+
array_filter($this->items, static fn (string $c) => $c !== ''),
92+
));
93+
}
94+
95+
public function toString(): string
96+
{
97+
return implode(' ', $this->toArray());
98+
}
99+
100+
/** @return list<string> */
101+
private static function tokenise(string $value): array
102+
{
103+
return array_values(array_filter(
104+
explode(' ', $value),
105+
static fn (string $t) => $t !== '',
106+
));
107+
}
108+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\ClassVariance;
6+
7+
/**
8+
* A callable that resolves CSS class strings from a variant definition.
9+
* Implementations hold base classes, variants, compound variants, and defaults,
10+
* and produce a merged class string for a given set of active props and slot.
11+
*/
12+
interface ClassVariance
13+
{
14+
/**
15+
* Resolve the class string for the given props and slot.
16+
*
17+
* @param array<string, string|bool> $props Active variant prop values.
18+
* @param string $slot Named slot to resolve (e.g. 'base', 'label'). When omitted
19+
* and the base defines a single slot, that slot is inferred automatically.
20+
*/
21+
public function __invoke(array $props = [], string $slot = ''): string;
22+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\ClassVariance\Classmaps;
6+
7+
/**
8+
* Immutable map of CSS class group definitions and their conflict rules.
9+
*
10+
* Each entry in $classGroups maps a group ID to a list of matchers.
11+
* Three matcher shapes are supported:
12+
*
13+
* string → exact class name match
14+
* e.g. 'block' matches only 'block'
15+
*
16+
* ['prefix'] → wildcard prefix match
17+
* e.g. ['p'] matches 'p', 'p-4', 'p-[2rem]'
18+
*
19+
* ['prefix' => ['v1', 'v2']] → constrained prefix match
20+
* matches 'prefix-v1', 'prefix-v2', etc.
21+
* Use '' as a suffix to match the bare prefix
22+
* e.g. ['border' => ['', '2', '4']] matches
23+
* 'border', 'border-2', 'border-4'
24+
*
25+
* Iteration order determines priority: the first group whose matcher fires wins.
26+
* Constrained prefix groups should come before wildcard prefix groups when both
27+
* share the same prefix (e.g. font-size before text-color for the 'text' prefix).
28+
*
29+
* $conflictingClassGroups maps a group ID to the list of group IDs it supersedes.
30+
* When a class from group A is encountered during merge, all previously accumulated
31+
* classes from groups listed under A are removed.
32+
*
33+
* Use extend() to merge additional definitions on top of an existing map (additive),
34+
* or override() to replace specific group definitions entirely.
35+
*/
36+
final readonly class Classmap
37+
{
38+
/**
39+
* @param array<string, list<string|array<string, list<string>>|array{0: string}>> $classGroups
40+
* @param array<string, list<string>> $conflictingClassGroups
41+
*/
42+
public function __construct(
43+
public array $classGroups = [],
44+
public array $conflictingClassGroups = [],
45+
) {}
46+
47+
/**
48+
* Find the group ID for the given class, or null if unknown.
49+
*/
50+
public function findGroup(string $class): ?string
51+
{
52+
foreach ($this->classGroups as $groupId => $matchers) {
53+
foreach ($matchers as $matcher) {
54+
if (is_string($matcher)) {
55+
// Exact match
56+
if ($matcher === $class) {
57+
return $groupId;
58+
}
59+
60+
continue;
61+
}
62+
63+
if (! is_array($matcher)) {
64+
continue;
65+
}
66+
67+
if (array_is_list($matcher)) {
68+
// ['prefix'] — wildcard: matches prefix itself or prefix-{anything}
69+
$prefix = $matcher[0];
70+
71+
if ($class === $prefix || str_starts_with($class, $prefix . '-')) {
72+
return $groupId;
73+
}
74+
} else {
75+
// ['prefix' => ['suffix1', 'suffix2']] — constrained suffix list
76+
$prefix = array_key_first($matcher);
77+
$suffixes = $matcher[$prefix];
78+
79+
foreach ($suffixes as $suffix) {
80+
if ($suffix === '' && $class === $prefix) {
81+
return $groupId;
82+
}
83+
84+
if ($suffix !== '' && $class === $prefix . '-' . $suffix) {
85+
return $groupId;
86+
}
87+
}
88+
}
89+
}
90+
}
91+
92+
return null;
93+
}
94+
95+
/**
96+
* Return a new map with $additions merged in.
97+
* For groups that exist in both maps, matchers are concatenated (additive).
98+
* Conflicting group lists are merged by union.
99+
*/
100+
public function extend(self $additions): self
101+
{
102+
$groups = $this->classGroups;
103+
104+
foreach ($additions->classGroups as $groupId => $matchers) {
105+
$groups[$groupId] = array_merge($groups[$groupId] ?? [], $matchers);
106+
}
107+
108+
$conflicts = $this->conflictingClassGroups;
109+
110+
foreach ($additions->conflictingClassGroups as $groupId => $conflicting) {
111+
$conflicts[$groupId] = array_values(array_unique([
112+
...($conflicts[$groupId] ?? []),
113+
...$conflicting,
114+
]));
115+
}
116+
117+
return new self($groups, $conflicts);
118+
}
119+
120+
/**
121+
* Return a new map with specific groups replaced entirely by $replacements.
122+
* Groups not present in $replacements are kept as-is.
123+
*/
124+
public function override(self $replacements): self
125+
{
126+
return new self(
127+
array_replace($this->classGroups, $replacements->classGroups),
128+
array_replace($this->conflictingClassGroups, $replacements->conflictingClassGroups),
129+
);
130+
}
131+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\ClassVariance\Classmaps;
6+
7+
/**
8+
* An empty Classmap — the default starting point for cv().
9+
*
10+
* No groups are declared, so the SeparatorClassMerger falls back purely to
11+
* its separator heuristic (everything before the first '-' is the group key).
12+
*
13+
* Pass a Classmap with explicit groups to GenericClassVarianceConfig when you
14+
* need conflict resolution for classes that share no common prefix.
15+
*/
16+
final class GenericClassmap
17+
{
18+
public static function default(): Classmap
19+
{
20+
return new Classmap();
21+
}
22+
}

0 commit comments

Comments
 (0)