Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Classes/Control/Attribute/Control.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Andersundsehr\FrontendStudio\Control\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_PARAMETER)]
final readonly class Control
{
public function __construct(public string $id)
{
}
}
17 changes: 17 additions & 0 deletions Classes/Control/Attribute/TypeControl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Andersundsehr\FrontendStudio\Control\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final readonly class TypeControl
{
public const string TAG_NAME = 'frontend_studio.control';

public function __construct(public string $id, public string $type = '*', public int $priority = 0)
{
}
}
20 changes: 20 additions & 0 deletions Classes/Control/ControlContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Andersundsehr\FrontendStudio\Control;

use Andersundsehr\FrontendStudio\Dto\ComponentArgumentMetadata;
use TYPO3Fluid\Fluid\Core\ViewHelper\ArgumentDefinition;

final readonly class ControlContext
{
public function __construct(
public ComponentArgumentMetadata $argument,
public mixed $value,
public bool $isFixtureValue,
public ?ArgumentDefinition $input = null,
public ?string $transformerSource = null,
) {
}
}
22 changes: 22 additions & 0 deletions Classes/Control/ControlDefinition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Andersundsehr\FrontendStudio\Control;

use InvalidArgumentException;

final readonly class ControlDefinition
{
public string $optionsJson;

/** @param array<string, mixed> $options */
public function __construct(public string $template, public string $module, public array $options = [])
{
if (!str_starts_with($template, 'EXT:') || str_contains($template, '..') || !str_starts_with($module, '@')) {
throw new InvalidArgumentException('Controls require an extension template and a registered import-map module.', 1791200010);
}

$this->optionsJson = json_encode($options, JSON_THROW_ON_ERROR);
}
}
58 changes: 58 additions & 0 deletions Classes/Control/TypeControls.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Andersundsehr\FrontendStudio\Control;

use Closure;
use InvalidArgumentException;

final class TypeControls
{
/** @var array<string, array{type: string, priority: int, provider: Closure(ControlContext): ?ControlDefinition}> */
private array $providers = [];

public function addControl(object $handler, string $method, string $id, string $type, int $priority): void
{
if ($id === '' || isset($this->providers[$id]) || !is_callable([$handler, $method])) {
throw new InvalidArgumentException('Duplicate control ID or invalid provider: ' . $id, 1791200011);
}

$this->providers[$id] = ['type' => $this->normalizeType($type), 'priority' => $priority, 'provider' => $handler->{$method}(...)];
}

public function resolve(ControlContext $context, ?string $override = null): ?ControlDefinition
{
if ($override !== null) {
$provider = $this->providers[$override] ?? throw new InvalidArgumentException('Unknown control: ' . $override, 1791200012);
return ($provider['provider'])($context) ?? throw new InvalidArgumentException('Control declined explicit selection: ' . $override, 1791200013);
}

$type = $this->normalizeType($context->argument->type);
$providers = array_filter($this->providers, static fn(array $provider): bool => $provider['type'] === $type || $provider['type'] === '*');
// Exact types precede wildcard providers; priority then stable ID breaks ties.
uksort($providers, static fn(string $a, string $b): int =>
($providers[$b]['type'] === $type) <=> ($providers[$a]['type'] === $type)
?: $providers[$b]['priority'] <=> $providers[$a]['priority'] ?: strcmp($a, $b));
foreach ($providers as $provider) {
$definition = ($provider['provider'])($context);
if ($definition !== null) {
return $definition;
}
}

return null;
}

private function normalizeType(string $type): string
{
$type = trim($type);
if (str_starts_with($type, '?')) {
$type = substr($type, 1) . '|null';
}

$parts = array_unique(array_map(static fn(string $part): string => ltrim(trim($part), '\\'), explode('|', $type)));
sort($parts);
return implode('|', $parts);
}
}
3 changes: 3 additions & 0 deletions Classes/Dto/ComponentVariantValueMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Andersundsehr\FrontendStudio\Dto;

use Andersundsehr\FrontendStudio\Control\ControlDefinition;

final readonly class ComponentVariantValueMetadata
{
public function __construct(
Expand All @@ -21,6 +23,7 @@ public function __construct(
/** @var array<string, string> */
public array $options = [],
public ?string $transformerSource = null,
public ?ControlDefinition $control = null,
) {
}
}
6 changes: 6 additions & 0 deletions Classes/Service/ComponentMetadataProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Andersundsehr\FrontendStudio\Service;

use DateTime;
use Andersundsehr\FrontendStudio\Control\ControlContext;
use Andersundsehr\FrontendStudio\Control\TypeControls;
use DateTimeInterface;
use Andersundsehr\FrontendStudio\ValueFormatter;
use Andersundsehr\FrontendStudio\Dto\ComponentArgumentMetadata;
Expand Down Expand Up @@ -43,6 +45,7 @@ public function __construct(
private ComponentDiscoveryProvider $componentDiscoveryProvider,
private TransformersFactory $transformersFactory,
private ComponentWritePolicy $writePolicy = new ComponentWritePolicy(),
private TypeControls $typeControls = new TypeControls(),
) {
}

Expand Down Expand Up @@ -204,6 +207,7 @@ private function mergeVariantValuesWithArguments(array $variantValues, array $ar
$this->isDateType($inputDefinition->getType()),
$this->getEnumOptions($inputDefinition->getType()),
$transformer->from,
$this->typeControls->resolve(new ControlContext($argument, $value, $hasValue, $inputDefinition, $transformer->from), $transformer->controls[$inputName] ?? null),
);
}

Expand All @@ -222,6 +226,7 @@ private function mergeVariantValuesWithArguments(array $variantValues, array $ar
$argument->required,
null,
$argument->name,
control: $this->typeControls->resolve(new ControlContext($argument, $argument->defaultValue, false)),
);
continue;
}
Expand All @@ -237,6 +242,7 @@ private function mergeVariantValuesWithArguments(array $variantValues, array $ar
$argument->required,
null,
$argument->name,
control: $this->typeControls->resolve(new ControlContext($argument, $variantValue->nativeValue, $variantValue->isFixtureValue)),
);
}

Expand Down
2 changes: 2 additions & 0 deletions Classes/Transformer/Transformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public function __construct(
* @var array<string, object>
*/
public array $services,
/** @var array<string, string> */
public array $controls = [],
) {
}

Expand Down
9 changes: 8 additions & 1 deletion Classes/Transformer/TransformerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Andersundsehr\FrontendStudio\Transformer;

use Closure;
use Andersundsehr\FrontendStudio\Control\Attribute\Control;
use InvalidArgumentException;
use Psr\Container\ContainerInterface;
use ReflectionFunction;
Expand Down Expand Up @@ -33,6 +34,7 @@ public function fromCallable(callable $transformer, string $from): Transformer
5914437566,
);
$services = [];
$controls = [];
foreach ($reflection->getParameters() as $parameter) {
$type = $parameter->getType()?->__toString() ?? throw new InvalidArgumentException(
sprintf(
Expand All @@ -48,6 +50,10 @@ public function fromCallable(callable $transformer, string $from): Transformer
}

$argumentName = $parameter->getName();
foreach ($parameter->getAttributes(Control::class) as $attribute) {
$controls[$argumentName] = $attribute->newInstance()->id;
}

if (!in_array($type, TransformersFactory::DEFAULT_SUPPORTED_TYPES) && !class_exists($type) && !enum_exists($type)) {
throw new InvalidArgumentException(
sprintf(
Expand Down Expand Up @@ -75,7 +81,8 @@ public function fromCallable(callable $transformer, string $from): Transformer
from: $from,
returnType: $returnType,
arguments: $arguments,
services: $services
services: $services,
controls: $controls
);
}
}
38 changes: 38 additions & 0 deletions Classes/ViewHelpers/ControlViewHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Andersundsehr\FrontendStudio\ViewHelpers;

use Andersundsehr\FrontendStudio\Dto\ComponentVariantValueMetadata;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
use TYPO3Fluid\Fluid\View\TemplateView;
use TYPO3\CMS\Fluid\Core\Rendering\RenderingContextFactory;
use Psr\Http\Message\ServerRequestInterface;

final class ControlViewHelper extends AbstractViewHelper
{
protected $escapeOutput = false;

public function initializeArguments(): void
{
$this->registerArgument('value', ComponentVariantValueMetadata::class, 'The trusted server-resolved control metadata', true);
}

public function render(): string
{
$value = $this->arguments['value'];
if (!$value instanceof ComponentVariantValueMetadata || $value->control === null || $this->renderingContext === null) {
return '';
}

$request = $this->renderingContext->hasAttribute(ServerRequestInterface::class)
? $this->renderingContext->getAttribute(ServerRequestInterface::class) : null;
$context = GeneralUtility::makeInstance(RenderingContextFactory::class)->create([], $request);
$context->getTemplatePaths()->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName($value->control->template));
$view = new TemplateView($context);
$view->assign('variantValue', $value);
return (string)$view->render();
}
}
23 changes: 23 additions & 0 deletions Configuration/Services.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<?php

use Andersundsehr\FrontendStudio\Transformer\Attribute\TypeTransformer;
use Andersundsehr\FrontendStudio\Control\Attribute\TypeControl;
use Andersundsehr\FrontendStudio\Control\TypeControls;
use Andersundsehr\FrontendStudio\Transformer\TypeTransformers;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
Expand All @@ -9,6 +11,27 @@
use Symfony\Component\DependencyInjection\Reference;

return static function (ContainerConfigurator $container, ContainerBuilder $containerBuilder): void {
$containerBuilder->registerAttributeForAutoconfiguration(
TypeControl::class,
static function (ChildDefinition $definition, TypeControl $attribute, Reflector $reflector): void {
if (!$reflector instanceof ReflectionMethod || !$reflector->isPublic() || $reflector->isStatic() || $reflector->isAbstract()) {
throw new InvalidArgumentException('TypeControl must target a public instance method.', 1791200014);
}

$definition->addTag(TypeControl::TAG_NAME, ['method' => $reflector->getName(), 'id' => $attribute->id, 'type' => $attribute->type, 'priority' => $attribute->priority]);
}
);
$containerBuilder->addCompilerPass(new class implements CompilerPassInterface {
public function process(ContainerBuilder $container): void
{
$registry = $container->findDefinition(TypeControls::class);
foreach ($container->findTaggedServiceIds(TypeControl::TAG_NAME) as $id => $tags) {
foreach ($tags as $tag) {
$registry->addMethodCall('addControl', [new Reference($id), $tag['method'], $tag['id'], $tag['type'], $tag['priority']]);
}
}
}
});
$containerBuilder->registerAttributeForAutoconfiguration(
TypeTransformer::class,
static function (ChildDefinition $definition, TypeTransformer $attribute, Reflector $reflector): void {
Expand Down
71 changes: 71 additions & 0 deletions Documentation/CustomControls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Custom controls

Controls change the inspector input, while transformers convert fixture data into component values.
Register a public instance method on an autoconfigured service:

```php
#[TypeControl(id: 'acme.html', type: 'string|Stringable', priority: 100)]
public function html(ControlContext $context): ?ControlDefinition
{
if ($context->input?->getName() !== 'string') {
return null;
}
return new ControlDefinition(
'EXT:acme/Resources/Private/Controls/Html.html',
'@acme/controls/html.js',
['toolbar' => ['bold', 'italic']],
);
}
```

Import the three control classes from `Andersundsehr\FrontendStudio\Control`
and `TypeControl` from its `Attribute` namespace.
The context contains the original component argument, current fixture value,
fixture presence, transformer input definition (including required/default state),
and transformer source. Metadata resolution never executes the transformer.

Union order, leading namespace separators and nullable shorthand are normalized.
Exact argument types precede `*` providers, then higher priority and alphabetical registration ID decide order.
A provider can return null to decline. IDs must be unique.
Wildcard providers can inspect the full context for more specialized matching.
A transformer parameter can select a registered provider explicitly with
`#[Control('acme.html')]` (same attribute namespace).
Unknown IDs or a declined explicit selection raise configuration errors.
Existing built-in controls remain the fallback.

The template receives `variantValue` and must emit one native fixture input
with `data-frontend-studio-variant-value`, `data-fixture-name`,
`data-fixture-type`, and `data-fixture-value-defined`.
Preserve its value, required/null state, name and accessible label.
Keep the native input available as the editor's accessible fallback.
Only service-provided `EXT:` template paths and import-map module names are accepted;
fixture/request values never select templates or modules.
Declare the module and any dependencies in your extension's `Configuration/JavaScriptModules.php`.
The inspector includes it using TYPO3's asset module mechanism.

The module exports an async default mount function:

```js
export default async function mount({ host, field, options, signal, changed }) {
// Initialize the editor from field.value; await editor startup here.
return {
getValue: () => field.value,
setValue: (value) => { field.value = value ?? ''; },
validate: () => '', // Empty string means valid; otherwise a user-facing error.
destroy: () => {},
};
}
```

After mount, value and validation methods must be synchronous.
Call `changed()` on editor changes, use `signal` for event listeners,
and release editor resources in `destroy()`.
Mounting blocks Save, Save as new and Reset; initialization failures remain blocked and show an error.
The initial snapshot is recorded after mounting.
Adapters participate in dirty tracking, Reset, Save, copy and preview URLs.
A late mount result is destroyed if navigation already removed the inspector.
Native controls retain their existing behavior.

The `preview_site_set` functional fixture demonstrates an extension-owned provider,
attributed transformer parameter, Fluid template and asynchronous JavaScript adapter.
Its nested `body.text` fixture shape is unchanged by control registration.
Loading
Loading