diff --git a/src/actions/CompileTwigComponent.php b/src/actions/CompileTwigComponent.php index 1a9b4e9..2628282 100644 --- a/src/actions/CompileTwigComponent.php +++ b/src/actions/CompileTwigComponent.php @@ -3,13 +3,7 @@ namespace markhuot\keystone\actions; use Craft; -use markhuot\keystone\base\AttributeBag; use markhuot\keystone\base\ComponentType; -use markhuot\keystone\base\FieldDefinition; -use markhuot\keystone\base\SlotDefinition; -use markhuot\keystone\models\Component; -use markhuot\keystone\models\ComponentData; -use markhuot\keystone\twig\Exports; use PhpParser\Node; use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\Namespace_; @@ -48,44 +42,18 @@ public function handle($force = false) return $fqcn; } - $fullTwigPath = Craft::$app->getView()->resolveTemplate($twigPath, $viewMode); - - Craft::$app->getView()->renderTemplate($twigPath, [ - 'component' => $component = (new Component), - 'exports' => $exports = new Exports, - 'props' => $props = new ComponentData, - 'attributes' => new AttributeBag, - ], $viewMode); - - $slotNames = $component->getAccessed()->map(fn (SlotDefinition $defn) => $defn->getConfig())->toArray(); - - $exportedPropTypes = collect($exports->exports['propTypes'] ?? []) - ->map(fn (FieldDefinition $defn, string $key) => $defn->handle($key)); - - $propTypes = $props->getAccessed() - ->merge($exportedPropTypes) - ->map(fn (FieldDefinition $defn) => $defn->config) - ->toArray(); - - $slotNameArray = '<'.'?php '.var_export($slotNames, true).';'; - $propTypeArray = '<'.'?php '.var_export($propTypes, true).';'; - $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7); - $propTypeAst = $parser->parse($propTypeArray)[0]->expr; - $slotNameAst = $parser->parse($slotNameArray)[0]->expr; $ast = $parser->parse(file_get_contents(__DIR__.'/../base/ComponentType.php')); $traverser = new NodeTraverser(); - $traverser->addVisitor(new class($this->handle, $viewMode.':'.$twigPath, $exports->exports, $propTypeAst, $className, $slotNameAst) extends NodeVisitorAbstract + $traverser->addVisitor(new class($this->handle, $viewMode.':'.$twigPath, [], $className) extends NodeVisitorAbstract { public function __construct( protected string $handle, protected string $twigPath, protected array $exports, - protected $propTypes, protected string $className, - protected $slotNames, ) { } @@ -118,16 +86,6 @@ public function enterNode(Node $node) new Stmt\Return_(new Node\Scalar\String_($this->twigPath)), ]; } - if ($node instanceof Stmt\ClassMethod && $node->name->name === 'getFieldConfig') { - $node->stmts = [ - new Stmt\Return_($this->propTypes), - ]; - } - if ($node instanceof Stmt\ClassMethod && $node->name->name === 'getSlotConfig') { - $node->stmts = [ - new Stmt\Return_($this->slotNames), - ]; - } } public function leaveNode(Node $node) diff --git a/src/actions/GetComponentType.php b/src/actions/GetComponentType.php index c7f7bb1..04755ab 100644 --- a/src/actions/GetComponentType.php +++ b/src/actions/GetComponentType.php @@ -6,11 +6,17 @@ use Illuminate\Support\Collection; use markhuot\keystone\base\ComponentType; use markhuot\keystone\events\RegisterComponentTypes; +use markhuot\keystone\models\Component; class GetComponentType { const EVENT_REGISTER_COMPONENT_TYPES = 'registerKeystoneComponentTypes'; + public function __construct( + protected ?Component $context = null + ) { + } + /** * @return Collection */ @@ -22,7 +28,7 @@ public function all(): Collection return $event->getTwigComponents() ->mapInto(CompileTwigComponent::class)->map->handle() ->merge($event->getClassComponents()) - ->map(fn ($className) => new $className); + ->map(fn ($className) => new $className($this->context)); } public function byType(string $type): ComponentType @@ -31,11 +37,11 @@ public function byType(string $type): ComponentType Event::trigger(static::class, static::EVENT_REGISTER_COMPONENT_TYPES, $event); if ($twigPath = $event->getTwigComponents()->get($type)) { - return new ((new CompileTwigComponent($twigPath, $type))->handle()); + return new ((new CompileTwigComponent($twigPath, $type))->handle())($this->context); } if ($className = $event->getClassComponents()->get($type)) { - return new $className; + return new $className($this->context); } throw new \RuntimeException('Could not find a component type definition for '.$type); diff --git a/src/base/ComponentType.php b/src/base/ComponentType.php index 84bbc9a..6bb7bc7 100644 --- a/src/base/ComponentType.php +++ b/src/base/ComponentType.php @@ -6,6 +6,9 @@ use craft\base\FieldInterface; use craft\helpers\Html; use Illuminate\Support\Collection; +use markhuot\keystone\models\Component; +use markhuot\keystone\models\ComponentData; +use markhuot\keystone\twig\Exports; use Twig\Markup; abstract class ComponentType @@ -22,10 +25,21 @@ abstract class ComponentType */ protected string $category = 'General'; + protected ?array $_exports = null; + + protected array $_accessedSlots = []; + + protected ?array $_schema = null; + + public function __construct( + protected ?Component $context = null + ) { + } + public function getName(): string { - if ($this->name !== null) { - return $this->name; + if ($name = $this->getExport('name', $this->name)) { + return $name; } $parts = explode('/', $this->handle); @@ -35,7 +49,7 @@ public function getName(): string public function getCategory(): string { - return $this->category; + return $this->getExport('category', $this->category); } public function getHandle(): string @@ -45,7 +59,9 @@ public function getHandle(): string public function getIcon(array $attributes = []): Markup|string { - return new Markup(Html::modifyTagAttributes($this->icon, $attributes), 'utf-8'); + $icon = $this->getExport('icon', $this->icon); + + return new Markup(Html::modifyTagAttributes($icon, $attributes), 'utf-8'); } public function render(array $variables = []): string @@ -63,8 +79,7 @@ abstract public function getTemplatePath(): string; public function getSlotDefinitions() { - return collect($this->getSlotConfig()) - ->mapIntoSpread(SlotDefinition::class); + return $this->getSchema()[1]; } public function getSlotDefinition(?string $slot) @@ -79,8 +94,7 @@ public function getSlotDefinition(?string $slot) */ public function getFieldDefinitions(): Collection { - return collect($this->getFieldConfig()) - ->mapInto(FieldDefinition::class); + return $this->getSchema()[0]; } /** @@ -97,12 +111,60 @@ public function getField(string $handle): ?FieldInterface return $this->getFields()->first(fn (FieldInterface $field) => $field->handle === $handle); } - public function hasSlots(): bool + public function getExports($dumb = false): array + { + if ($this->_exports) { + return $this->_exports; + } + + $componentData = $this->context?->getProps() ?? new ComponentData; + $componentData->type = $this->getHandle(); + $component = $this->context ?? new Component; + $component->populateRelation('data', $componentData); + $attributes = $component->getAttributeBag() ?? new AttributeBag; + + $this->render([ + 'component' => $component, + 'props' => $props = ($dumb ? new ComponentData : $componentData), + 'attributes' => $attributes, + 'exports' => $exports = new Exports, + ]); + + $exports = ['exports' => $exports->exports, 'props' => $props]; + + if ($dumb) { + return $exports; + } + + return $this->_exports = $exports; + } + + public function getExport(string $name, mixed $default = null) { - return (bool) count($this->getSlotConfig()); + return $this->getExports()['exports'][$name] ?? $default; } - abstract protected function getFieldConfig(): array; + public function defineSlot(string $slotName = null): SlotDefinition + { + return $this->_accessedSlots[$slotName] ??= new SlotDefinition($this->context, $slotName); + } - abstract protected function getSlotConfig(): array; + protected function getSchema(): array + { + if ($this->_schema !== null) { + return $this->_schema; + } + + ['exports' => $exports, 'props' => $props] = $this->getExports(true); + + $slotDefinitions = collect($this->_accessedSlots); + + $exportedFieldDefinitions = collect($exports['propTypes'] ?? []) + ->map(fn (FieldDefinition $defn, string $key) => $defn->handle($key)); + + $fieldDefinitions = $props->getAccessed() + ->merge($exportedFieldDefinitions); + + return $this->_schema = [$fieldDefinitions, $slotDefinitions]; + } } diff --git a/src/models/Component.php b/src/models/Component.php index 51a2ee1..5f512ee 100644 --- a/src/models/Component.php +++ b/src/models/Component.php @@ -5,7 +5,6 @@ use craft\base\ElementInterface; use craft\base\FieldInterface; use craft\db\ActiveQuery; -use craft\helpers\Html; use Illuminate\Support\Collection; use markhuot\keystone\actions\GetComponentType; use markhuot\keystone\actions\NormalizeFieldDataForComponent; @@ -32,9 +31,6 @@ */ class Component extends ActiveRecord { - /** @var array */ - protected array $accessed = []; - /** @var array */ protected ?array $slotted = null; @@ -42,6 +38,8 @@ class Component extends ActiveRecord protected ?Component $renderParent = null; + protected ?ComponentType $_type = null; + public static function factory(): \markhuot\keystone\factories\Component { return new \markhuot\keystone\factories\Component; @@ -93,7 +91,21 @@ public function setType(string $type): self public function getType(): ComponentType { - return (new GetComponentType)->byType($this->data->type); + return $this->_type ??= (new GetComponentType($this))->byType($this->data->type); + } + + public function setComponentType(ComponentType $type): self + { + $this->_type = $type; + + return $this; + } + + public function refresh() + { + $this->slotted = null; + + return parent::refresh(); } public function __get($name) @@ -225,32 +237,9 @@ public function getAttributeBag(): AttributeBag return new AttributeBag($this->data->getDataAttributes()); } - /** - * @return array - */ - public function getExports(): array - { - $exports = new class - { - /** @var array */ - public array $exports = []; - - public function add(mixed $key, mixed $value): void - { - $this->exports[$key] = $value; - } - }; - - $this->render([ - 'exports' => $exports, - ]); - - return $exports->exports; - } - - public function getIcon(?array $attributes) + public function getSummary(): ?string { - return Html::modifyTagAttributes($this->getExports()['icon'], $attributes) ?? $this->getType()->getIcon($attributes); + return $this->getType()->getExport('summary'); } public function __toString(): string @@ -270,14 +259,9 @@ public function isParentOf(Component $component, string $slotName = null): bool return $this->getChildPath() === $component->path && $slotName === $component->slot; } - public function defineSlot(string $slotName = null): SlotDefinition - { - return $this->accessed[$slotName] ??= new SlotDefinition($this, $slotName); - } - public function getSlot(string $name = null): SlotCollection { - $this->accessed[$name] ??= new SlotDefinition($this, $name); + $this->getType()->defineSlot($name); if ($this->slotted !== null) { $components = collect($this->slotted) diff --git a/src/templates/components/elementquery.twig b/src/templates/components/elementquery.twig index d20501b..bc0aa20 100644 --- a/src/templates/components/elementquery.twig +++ b/src/templates/components/elementquery.twig @@ -4,7 +4,7 @@ {% export propTypes = { search: field('\\markhuot\\keystone\\fields\\Condition') } %} -{% set defaultSlot = component.defineSlot() %} +{% set defaultSlot = component.getType().defineSlot() %} {% set elements = props.search.all()|default([]) %} {% for element in elements %} {{ defaultSlot.render({element: element}) }} diff --git a/src/templates/components/icon.twig b/src/templates/components/icon.twig index 81c654f..24339e6 100644 --- a/src/templates/components/icon.twig +++ b/src/templates/components/icon.twig @@ -6,7 +6,7 @@ }))) } %} {% set defaultIcon %}{% endset %} -{% export icon = props.icon ? svg(parseEnv('@templates/icons/' ~ props.icon)) : defaultIcon %} +{% export icon = props.icon|length > 0 ? svg(parseEnv('@templates/icons/' ~ props.icon)) : defaultIcon %} {% if props.icon %} {{ svg(parseEnv('@templates/icons/' ~ props.icon))|attr(attributes.toArray()) }} {% endif %} diff --git a/src/templates/field.twig b/src/templates/field.twig index b3128d1..10dfd3e 100644 --- a/src/templates/field.twig +++ b/src/templates/field.twig @@ -33,9 +33,9 @@
{% set params = {id: child.id, elementId: child.elementId, fieldId: field.id} %} -
{{ child.getIcon({class: 'k-w-4 k-inline'})|raw }}
+
{{ child.getType().getIcon({class: 'k-w-4 k-inline'})|raw }}
{{ child.getType().getName() }} - {{ child.getExports().summary|default }} + {{ child.getSummary() }}
{{ hiddenInput('nodes[]', child.id ~ '@' ~ child.dateUpdated) }} diff --git a/src/twig/SlotTokenNode.php b/src/twig/SlotTokenNode.php index cd7b561..afcff8a 100644 --- a/src/twig/SlotTokenNode.php +++ b/src/twig/SlotTokenNode.php @@ -16,7 +16,7 @@ public function compile(\Twig\Compiler $compiler) { $compiler ->addDebugInfo($this) - ->write('echo $context[\'component\']?->defineSlot('); + ->write('echo $context[\'component\']?->getType()->defineSlot('); $name = $this->getAttribute('name'); if ($name) { diff --git a/tests/ExportsTest.php b/tests/ExportsTest.php new file mode 100644 index 0000000..157bf3f --- /dev/null +++ b/tests/ExportsTest.php @@ -0,0 +1,26 @@ +type('site/components/dynamic-prop-types')->create(); + $component->data->merge(['foo' => 'bar']); + $exports = $component->getType()->getExports(); + + expect($exports)->propTypes->foo->placeholder + ->toBe(null); +}); + +it('gets data even with circular references', function () { + $component = Component::factory()->type('site/components/dynamic-prop-types')->create(); + $component->data->merge(['foo' => 'bar']); + + expect(trim($component->render()))->toBe('bar'); +}); + +it('gets dynamic summaries', function () { + $component = Component::factory()->type('site/components/summary-export')->create(); + $component->data->merge(['foo' => 'bar']); + + expect($component->getSummary())->toBe('bar'); +}); diff --git a/tests/ParseTwigForSchemaTest.php b/tests/ParseTwigForSchemaTest.php index c4ab99f..3379a47 100644 --- a/tests/ParseTwigForSchemaTest.php +++ b/tests/ParseTwigForSchemaTest.php @@ -4,6 +4,7 @@ use markhuot\keystone\actions\CompileTwigComponent; use markhuot\keystone\actions\GetComponentType; use markhuot\keystone\actions\GetFileMTime; +use markhuot\keystone\models\Component; it('throws on unknown component', function () { $this->expectException(RuntimeException::class); @@ -48,8 +49,10 @@ it('gets field and slot schema', function () { $fqcn = (new CompileTwigComponent('site:component-with-fields.twig', 'test/component-with-fields'))->handle(); + $component = new Component; + $component->setComponentType($componentType = new $fqcn($component)); - expect(new $fqcn) + expect($componentType) ->getFieldDefinitions()->toHaveCount(1) ->getSlotDefinitions()->toHaveCount(1); }); @@ -63,8 +66,10 @@ it('gets slot restrictions', function () { $fqcn = (new CompileTwigComponent('site:slot-with-restrictions.twig', 'test/slot-with-restrictions'))->handle(); + $component = new Component; + $component->setComponentType($componentType = new $fqcn($component)); - expect(new $fqcn) + expect($componentType) ->getSlotDefinitions()->toHaveCount(2) ->getSlotDefinition(null)->getWhitelist()->toContain('allowed/type') ->getSlotDefinition(null)->allows('allowed/type')->toBeTrue() diff --git a/tests/templates/components/dynamic-prop-types.twig b/tests/templates/components/dynamic-prop-types.twig new file mode 100644 index 0000000..dab7bbb --- /dev/null +++ b/tests/templates/components/dynamic-prop-types.twig @@ -0,0 +1,4 @@ +{% export propTypes = { + foo: field('\\craft\\fields\\PlainText').placeholder(props.foo), +} %} +{{ props.foo }} diff --git a/tests/templates/components/slot-with-defaults.twig b/tests/templates/components/slot-with-defaults.twig index c819f04..b7b101b 100644 --- a/tests/templates/components/slot-with-defaults.twig +++ b/tests/templates/components/slot-with-defaults.twig @@ -1,5 +1,5 @@
- {{ component.defineSlot() + {{ component.getType().defineSlot() .defaults({type: 'keystone/text', data: {text: 'foo'}}) .defaults({type: 'keystone/text', data: {text: 'bar'}}) }}
diff --git a/tests/templates/components/slot-with-nested-defaults.twig b/tests/templates/components/slot-with-nested-defaults.twig index dfab941..ed2ac10 100644 --- a/tests/templates/components/slot-with-nested-defaults.twig +++ b/tests/templates/components/slot-with-nested-defaults.twig @@ -1,5 +1,5 @@
- {{ component.defineSlot() + {{ component.getType().defineSlot() .defaults({type: 'keystone/text', data: {text: 'foo'}}) .defaults({type: 'keystone/section', slots: {(null): [ {type: 'keystone/text', data: {text: 'bar'}} diff --git a/tests/templates/components/summary-export.twig b/tests/templates/components/summary-export.twig new file mode 100644 index 0000000..c5c23f9 --- /dev/null +++ b/tests/templates/components/summary-export.twig @@ -0,0 +1 @@ +{% export summary = props.foo %}