diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0408af..e9b3a4fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.36.0...develop) ### Added +- Block Bindings live in their own package, [`pollora/block-bindings`](https://github.com/Pollora/block-bindings), which the framework requires: the resolver, the source builder and registry, `BindingContext`, the four sources Pollora ships (`pollora/post-meta`, `pollora/term-meta`, `pollora/author-meta`, `pollora/option`), `BlockBindingDiscovery`, the WordPress adapters, the editor data and script (`block-bindings.js` ships with the package) and the preview route, with their tests. Class names do not change. The framework keeps what needs Laravel: `BlockBindingServiceProvider`, `config/block-bindings.php`, the Laravel implementations of the package's contracts (`LaravelMethodInvoker`, `LaravelBindingEvents`, `LaravelPostFinder`), `pollora:binding:list`, `pollora:make:binding` and the `pollora:doctor` check - Roles and capabilities live in their own package, [`pollora/roles`](https://github.com/Pollora/roles), which the framework requires: `RoleDefinitionBuilder`, `RoleRegistry`, `RoleCompiler`, `PostTypeCapabilityMap`, the capability readers, the WordPress adapters (stored roles, `WP_Roles` injection, label translation, role usage) and `RoleDiscovery`, with their tests. Class names do not change. The framework keeps the Laravel side: `RoleServiceProvider`, the `role` middleware, the `@role` directive, the `HasRoles` bridge (`$user->can()`, `@can`), the `pollora:roles:*` commands and the `pollora:doctor` check - Typed meta lives in its own package, [`pollora/meta`](https://github.com/Pollora/meta), which the framework requires: `MetaAccessor` (`of()`, `schemas()`, `schemaFor()`, `extend()`), the fluent `MetaRecord`, the schema builder and repository, `MetaAuditor`, `MetaUiDrivers`, the value caster, the WordPress adapters, `MetaDiscovery` and `MetaUiDriverConformance`, with their tests. Class names do not change. The framework keeps what needs Laravel: `MetaServiceProvider`, the `Meta` facade and `HasTypedMeta`, the Laravel validator (`LaravelMetaValidator`), the `pollora:meta:*` commands and the `pollora:doctor` check - The asset classes live in their own package, [`pollora/assets`](https://github.com/Pollora/assets), which the framework requires: `AssetManager`, `AssetContainer`, `AssetEnqueuer` (`Asset::add()->container()->useVite()`), `AssetFile`, the record and the wp-admin notice of a missing build. Class names do not change. The package also resolves Vite builds without Laravel, for a classic theme, a plugin or a Bedrock site. The framework keeps what needs Laravel: the service provider, the `Asset` facade, `ViteManager` (Laravel's Vite, where `ASSET_URL`, a CDN and the application URL keep applying), `RootAssetManager` and the Laravel implementations of the package's three contracts (`ViteManagerFactory`, `RuntimeContext`, `PublicPath`) diff --git a/composer.json b/composer.json index 10e60937..c052f440 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "pollora/ajax": "^1.1", "pollora/assets": "^0.1", "pollora/attributes": "^0.1.1 || ^1.0", + "pollora/block-bindings": "^0.1", "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^1.2", "pollora/meta": "^0.1", diff --git a/resources/js/block-bindings.js b/resources/js/block-bindings.js deleted file mode 100644 index 8cd6de2a..00000000 --- a/resources/js/block-bindings.js +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Pollora — the editor side of Block Bindings. - * - * The server registers every Pollora source (#[BlockBinding] classes and the - * pollora/* meta sources) and the editor learns their names and labels. This - * script adds what the editor needs to use them: - * - * - getFieldsList: the fields of the source, so the block's "Attributes" - * panel offers them; a meta source offers the meta of the post type (or - * taxonomy) being edited; - * - getValues: the value each bound block shows, computed on the server by - * the same code as the page and asked in one request for all the blocks - * waiting for a value (POST pollora/v1/block-bindings/resolve). - * - * The values are read-only: the block shows them, the editor does not write - * them back. While a value loads, and when the source gives none, the block - * shows the field's label. - * - * Plain JavaScript on the wp.* globals, shipped without a build as the inline - * script of the "pollora-block-bindings" handle, after window.polloraBlockBindings. - */ -(function (wp, root) { - 'use strict'; - - var config = root.polloraBlockBindings; - - if (!config || !wp.blocks || !wp.blocks.registerBlockBindingsSource) { - return; - } - - var STORE = 'pollora/block-bindings'; - - /** Wait for the other blocks of the same render before asking the server. */ - var BATCH_DELAY = 20; - - var pending = {}; - var timer = null; - - /** The key of one bound attribute, for the store and the request. The block decides its escaping. */ - function keyOf(sourceName, args, context, blockName, attribute) { - return JSON.stringify([sourceName, args || {}, context.postId || null, context.postType || null, context.termId || null, context.taxonomy || null, blockName, attribute]); - } - - function flush(dispatch) { - var batch = pending; - var byContext = {}; - - pending = {}; - timer = null; - - Object.keys(batch).forEach(function (key) { - var item = batch[key]; - var contextKey = JSON.stringify(item.context); - - byContext[contextKey] = byContext[contextKey] || { context: item.context, bindings: [] }; - byContext[contextKey].bindings.push({ key: key, source: item.source, args: item.args, block: item.block, attribute: item.attribute }); - }); - - Object.keys(byContext).forEach(function (contextKey) { - var request = byContext[contextKey]; - var keys = request.bindings.map(function (binding) { return binding.key; }); - - wp.apiFetch({ path: '/' + config.route, method: 'POST', data: request }) - .then(function (response) { - var values = {}; - - keys.forEach(function (key) { - values[key] = response && response.values && key in response.values ? response.values[key] : null; - }); - dispatch.receiveValues(values); - }) - .catch(function () { - var values = {}; - - keys.forEach(function (key) { values[key] = null; }); - dispatch.receiveValues(values); - }); - }); - } - - var store = wp.data.createReduxStore(STORE, { - reducer: function (state, action) { - state = state || {}; - - if (action.type === 'RECEIVE_VALUES') { - return Object.assign({}, state, action.values); - } - - return state; - }, - actions: { - receiveValues: function (values) { - return { type: 'RECEIVE_VALUES', values: values }; - }, - }, - selectors: { - /** The value of a bound attribute; undefined while it loads. */ - getValue: function (state, key) { - return state[key]; - }, - }, - resolvers: { - getValue: function (key) { - return function (thunk) { - var item = JSON.parse(key); - - pending[key] = { - source: item[0], - args: item[1], - context: pick({ postId: item[2], postType: item[3], termId: item[4], taxonomy: item[5] }), - block: item[6], - attribute: item[7], - }; - - if (!timer) { - timer = setTimeout(function () { flush(thunk.dispatch); }, BATCH_DELAY); - } - }; - }, - }, - }); - - wp.data.register(store); - - /** The members that have a value. */ - function pick(object) { - var result = {}; - - Object.keys(object).forEach(function (name) { - if (object[name] !== null && object[name] !== undefined) { - result[name] = object[name]; - } - }); - - return result; - } - - function sameArgs(a, b) { - return JSON.stringify(a || {}) === JSON.stringify(b || {}); - } - - /** The fields a source offers in this context. */ - function fieldsFor(source, context) { - if (source.kind === 'meta') { - var subtype = source.subtype ? context[source.subtype] : null; - - return (source.fields[''] || []).concat(subtype ? source.fields[subtype] || [] : []); - } - - if (source.postTypes.length && (!context.postType || source.postTypes.indexOf(context.postType) === -1)) { - return []; - } - - return source.fields; - } - - function labelOf(source, args, context) { - var field = fieldsFor(source, context).filter(function (item) { return sameArgs(item.args, args); })[0]; - - return field ? field.label : null; - } - - Object.keys(config.sources).forEach(function (name) { - var source = config.sources[name]; - - wp.blocks.registerBlockBindingsSource({ - name: name, - - getFieldsList: function (options) { - return fieldsFor(source, options.context || {}); - }, - - getValues: function (options) { - var context = options.context || {}; - var values = {}; - var blockEditorStore = options.select(wp.blockEditor.store); - var attributes = options.clientId ? blockEditorStore.getBlockAttributes(options.clientId) || {} : {}; - var blockName = options.clientId ? blockEditorStore.getBlockName(options.clientId) : null; - - Object.keys(options.bindings).forEach(function (attribute) { - var args = options.bindings[attribute].args; - var value = options.select(STORE).getValue(keyOf(name, args, context, blockName, attribute)); - - if (value === undefined || value === null || value === '') { - // Loading, or no value: what the block holds, else the field's label - value = attributes[attribute] || labelOf(source, args, context) || ''; - } - - values[attribute] = typeof value === 'number' || typeof value === 'string' ? value : String(value); - }); - - return values; - }, - }); - }); -})(window.wp, window); diff --git a/src/BlockBinding/Application/Services/BindingReferenceInspector.php b/src/BlockBinding/Application/Services/BindingReferenceInspector.php deleted file mode 100644 index 4ed28747..00000000 --- a/src/BlockBinding/Application/Services/BindingReferenceInspector.php +++ /dev/null @@ -1,130 +0,0 @@ - $options The options `pollora/option` may read - */ - public function __construct( - private BindingSourceRegistry $sources, - private MetaSchemaRepository $schemas, - private array $options = [], - ) {} - - /** - * Whether the source is one of Pollora's, which the inspector can check. - */ - public function knows(string $sourceName): bool - { - return $this->sources->find($sourceName) instanceof BindingSource; - } - - /** - * Why the binding shows nothing, or null when it can show its value. - * - * @param array $args - */ - public function problem(string $sourceName, array $args): ?string - { - $source = $this->sources->find($sourceName); - - if (! $source instanceof BindingSource) { - return null; - } - - return match ($sourceName) { - 'pollora/post-meta' => $this->metaProblem(MetaObjectType::Post, $args, static fn (MetaDefinition $definition): bool => $definition->showInRest && ! $definition->isProtected(), 'it is not exposed in REST (showInRest: true) or its key is protected'), - 'pollora/term-meta' => $this->metaProblem(MetaObjectType::Term, $args, static fn (MetaDefinition $definition): bool => $definition->showInRest && ! $definition->isProtected(), 'it is not exposed in REST (showInRest: true) or its key is protected'), - 'pollora/author-meta' => $this->metaProblem(MetaObjectType::User, $args, static fn (MetaDefinition $definition): bool => $definition->public, 'it is not marked #[Meta(public: true)]'), - 'pollora/option' => $this->optionProblem($args), - default => $this->fieldProblem($source, $args), - }; - } - - /** - * @param array $args - */ - private function fieldProblem(BindingSource $source, array $args): ?string - { - if ($source->isInvokable()) { - return null; - } - - $field = $args['field'] ?? null; - - if (! is_string($field) || $field === '') { - return sprintf('no "field" argument; %s has %s', $source->name, $this->fieldList($source)); - } - - return $source->field($field) instanceof BindingFieldDefinition - ? null - : sprintf('the field "%s" does not exist; %s has %s', $field, $source->name, $this->fieldList($source)); - } - - /** - * @param array $args - * @param \Closure(MetaDefinition): bool $readable - */ - private function metaProblem(MetaObjectType $objectType, array $args, \Closure $readable, string $unreadable): ?string - { - $key = $args['key'] ?? null; - - if (! is_string($key) || $key === '') { - return 'no "key" argument'; - } - - $found = false; - - foreach ($this->schemas->all() as $schema) { - if ($schema->objectType !== $objectType || ! ($definition = $schema->find($key)) instanceof MetaDefinition) { - continue; - } - - if ($readable($definition)) { - return null; - } - - $found = true; - } - - return $found - ? sprintf('the meta "%s" is never shown: %s', $key, $unreadable) - : sprintf('no #[Meta] declares the %s meta "%s"', $objectType->value, $key); - } - - /** - * @param array $args - */ - private function optionProblem(array $args): ?string - { - $name = $args['name'] ?? null; - - if (! is_string($name) || $name === '') { - return 'no "name" argument'; - } - - return in_array($name, $this->options, true) ? null : sprintf('the option "%s" is not listed in block-bindings.options', $name); - } - - private function fieldList(BindingSource $source): string - { - return 'the fields '.implode(', ', array_map(static fn (string $name): string => '"'.$name.'"', array_keys($source->fields))); - } -} diff --git a/src/BlockBinding/Application/Services/BindingResolver.php b/src/BlockBinding/Application/Services/BindingResolver.php deleted file mode 100644 index 7c99af7e..00000000 --- a/src/BlockBinding/Application/Services/BindingResolver.php +++ /dev/null @@ -1,285 +0,0 @@ - Values already resolved in this request - */ - private array $resolved = []; - - /** - * @var array Source instances, made on first use - */ - private array $instances = []; - - private readonly MethodInvokerInterface $invoker; - - public function __construct( - private readonly ContainerInterface $container, - private readonly ContentVisibilityInterface $visibility, - private readonly ValuePresenterInterface $presenter, - private readonly ?MetaAccessor $metaAccessor = null, - private readonly bool $debug = false, - private readonly ?LoggerInterface $logger = null, - ?MethodInvokerInterface $invoker = null, - private readonly ?BindingEventsInterface $events = null, - private readonly ?PostFinderInterface $posts = null, - ) { - $this->invoker = $invoker ?? new ReflectionMethodInvoker($container); - } - - /** - * The value of a bound attribute, or null to keep what the block holds. - * - * @param array $args Arguments of the binding - */ - public function resolve(BindingSource $source, array $args, ?\WP_Block $block, string $attribute): mixed - { - $attributeSource = $block?->block_type?->attributes[$attribute]['source'] ?? null; - - return $this->resolveIn($source, $args, $block instanceof \WP_Block ? $block->context : [], $block, $attribute, is_string($attributeSource) ? $attributeSource : null); - } - - /** - * The value the editor previews for a bound attribute: the same checks, the - * same field and the same escaping, from the block context the editor sends. - * - * @param array $args Arguments of the binding - * @param array $blockContext `postId`, `postType`, `termId`, `taxonomy` - * @param string|null $attributeSource The `source` of the attribute in the block type (`rich-text`, `attribute`…) - */ - public function preview(BindingSource $source, array $args, array $blockContext, string $attribute, ?string $attributeSource = null): mixed - { - $value = $this->resolveIn($source, $args, $blockContext, null, $attribute, $attributeSource); - - return is_bool($value) ? $this->presenter->boolean($value) : $value; - } - - /** - * @param array $args - * @param array $blockContext - */ - private function resolveIn(BindingSource $source, array $args, array $blockContext, ?\WP_Block $block, string $attribute, ?string $attributeSource = null): mixed - { - $context = $this->context($args, $blockContext, $block, $attribute); - - if (! $this->isVisible($context) || ! $source->answersFor($context->postType)) { - return null; - } - - $field = $source->isInvokable() - ? new BindingFieldDefinition('', '', BindingFieldType::Text, '__invoke') - : $source->field(is_string($args['field'] ?? null) ? $args['field'] : ''); - - if (! $field instanceof BindingFieldDefinition) { - return null; - } - - $key = $this->cacheKey($source, $context, $attributeSource); - - if (array_key_exists($key, $this->resolved)) { - $this->announce($source, $field, $context, 0.0, true, $this->resolved[$key] !== null); - - return $this->resolved[$key]; - } - - $start = hrtime(true); - $value = $this->call($source, $field, $context); - $milliseconds = (hrtime(true) - $start) / 1_000_000; - - $this->resolved[$key] = $this->present($value, $field->type, $attributeSource); - $this->announce($source, $field, $context, $milliseconds, false, $this->resolved[$key] !== null); - - return $this->resolved[$key]; - } - - /** - * Tell debugging tools what was resolved, when one is listening. - */ - private function announce(BindingSource $source, BindingFieldDefinition $field, BindingContext $context, float $milliseconds, bool $cached, bool $hasValue): void - { - if (! $this->events instanceof BindingEventsInterface || ! $this->events->isListening()) { - return; - } - - $this->events->dispatch(new BindingResolved( - source: $source->name, - field: $field->name, - attribute: $context->attribute, - postId: $context->postId, - milliseconds: round($milliseconds, 3), - cached: $cached, - hasValue: $hasValue, - )); - } - - /** - * @param array $args - * @param array $blockContext - */ - private function context(array $args, array $blockContext, ?\WP_Block $block, string $attribute): BindingContext - { - return new BindingContext( - args: $args, - attribute: $attribute, - postId: $this->positiveInt($blockContext['postId'] ?? null), - postType: is_string($blockContext['postType'] ?? null) ? $blockContext['postType'] : null, - termId: $this->positiveInt($blockContext['termId'] ?? null), - taxonomy: is_string($blockContext['taxonomy'] ?? null) ? $blockContext['taxonomy'] : null, - block: $block, - metaAccessor: $this->metaAccessor, - postFinder: $this->posts, - ); - } - - private function isVisible(BindingContext $context): bool - { - if ($context->postId !== null && ! $this->visibility->canShowPost($context->postId)) { - return false; - } - - return $context->termId === null || $context->taxonomy === null || $this->visibility->canShowTerm($context->termId, $context->taxonomy); - } - - private function call(BindingSource $source, BindingFieldDefinition $field, BindingContext $context): mixed - { - try { - $instance = $this->instances[$source->class] ??= $this->container->get($source->class); - - if (! $this->debug) { - return $this->invoker->call($instance, $field->method, [BindingContext::class => $context, 'context' => $context]); - } - - $start = hrtime(true); - $value = $this->invoker->call($instance, $field->method, [BindingContext::class => $context, 'context' => $context]); - $this->reportSlow($source, $field, $context, (hrtime(true) - $start) / 1_000_000); - - return $value; - } catch (Throwable $throwable) { - if ($this->debug) { - throw $throwable; - } - - $this->logger?->error(sprintf( - 'The block binding "%s" failed on %s: %s', - $source->name, - $context->postId === null ? 'no post' : 'post '.$context->postId, - $throwable->getMessage() - ), ['exception' => $throwable]); - - return null; - } - } - - private function reportSlow(BindingSource $source, BindingFieldDefinition $field, BindingContext $context, float $milliseconds): void - { - if ($milliseconds <= self::SLOW_FIELD_MS) { - return; - } - - $this->logger?->warning(sprintf( - 'The block binding "%s"%s took %d ms on %s', - $source->name, - $field->name === '' ? '' : sprintf(' (field "%s")', $field->name), - (int) round($milliseconds), - $context->postId === null ? 'no post' : 'post '.$context->postId, - ), [ - 'source' => $source->name, - 'field' => $field->name, - 'post' => $context->postId, - 'milliseconds' => round($milliseconds, 1), - ]); - } - - private function present(mixed $value, BindingFieldType $type, ?string $attributeSource): mixed - { - if ($value === null) { - return null; - } - - $inHtml = in_array($attributeSource, self::HTML_SOURCES, true); - - if (is_object($value) && method_exists($value, 'toHtml')) { - return $this->presenter->richText((string) $value->toHtml()); - } - - if (is_bool($value)) { - return $inHtml ? $this->presenter->html($this->presenter->boolean($value)) : $value; - } - - if ((is_int($value) || is_float($value)) && ! $inHtml && ! $type->isUrl()) { - return $value; - } - - $string = $value instanceof Stringable || is_scalar($value) ? (string) $value : ''; - - return match (true) { - $type->isUrl() => $this->presenter->url($string), - $inHtml => $this->presenter->html($string), - default => $string, - }; - } - - private function cacheKey(BindingSource $source, BindingContext $context, ?string $attributeSource): string - { - return implode('|', [ - $source->name, - $context->attribute, - (string) $attributeSource, - (string) $context->postId, - (string) $context->termId, - (string) $context->block?->name, - serialize($context->args), - ]); - } - - private function positiveInt(mixed $value): ?int - { - return is_numeric($value) && (int) $value > 0 ? (int) $value : null; - } -} diff --git a/src/BlockBinding/Application/Services/BindingSourceBuilder.php b/src/BlockBinding/Application/Services/BindingSourceBuilder.php deleted file mode 100644 index 94472f40..00000000 --- a/src/BlockBinding/Application/Services/BindingSourceBuilder.php +++ /dev/null @@ -1,168 +0,0 @@ -getAttributes(BlockBinding::class)[0] ?? null)?->newInstance(); - - if (! $attribute instanceof BlockBinding) { - throw InvalidBindingSourceException::forClass($class, 'the class has no #[BlockBinding] attribute.'); - } - - if (preg_match(self::NAME_PATTERN, $attribute->name) !== 1) { - throw InvalidBindingSourceException::forClass($class, sprintf( - 'the name "%s" must be "namespace/name", in lowercase letters, digits and dashes.', - $attribute->name - )); - } - - if (! $reflection->isInstantiable()) { - throw InvalidBindingSourceException::forClass($class, 'the class cannot be instantiated.'); - } - - $fields = $this->buildFields($reflection); - - if ($fields === [] && ! $reflection->hasMethod('__invoke')) { - throw InvalidBindingSourceException::forClass($class, 'mark a public method #[BindingField], or define __invoke() to answer every call.'); - } - - if ($fields === []) { - $this->assertReturnType($class, $reflection->getMethod('__invoke')); - } - - return new BindingSource( - name: $attribute->name, - label: $attribute->label ?? Naming::headline($reflection->getShortName()), - class: $class, - usesContext: $attribute->usesContext, - postTypes: $attribute->postTypes, - fields: $fields, - ); - } - - /** - * @param ReflectionClass $reflection - * @return array - */ - private function buildFields(ReflectionClass $reflection): array - { - $class = $reflection->getName(); - $fields = []; - - foreach ($reflection->getMethods() as $method) { - $attribute = ($method->getAttributes(BindingField::class)[0] ?? null)?->newInstance(); - - if (! $attribute instanceof BindingField) { - continue; - } - - if (! $method->isPublic() || $method->isStatic()) { - throw InvalidBindingSourceException::forField($class, $method->getName(), 'a field must be a public, non-static method.'); - } - - $this->assertReturnType($class, $method); - - $name = $attribute->name ?? Naming::snake($method->getName()); - - if (isset($fields[$name])) { - throw InvalidBindingSourceException::forField($class, $method->getName(), sprintf( - 'the field "%s" is already answered by %s().', - $name, - $fields[$name]->method - )); - } - - $type = $attribute->type instanceof BindingFieldType ? $attribute->type : BindingFieldType::tryFrom($attribute->type); - - if (! $type instanceof BindingFieldType) { - throw InvalidBindingSourceException::forField($class, $method->getName(), sprintf( - 'the type "%s" is unknown; use text, url or image.', - (string) $attribute->type - )); - } - - $fields[$name] = new BindingFieldDefinition( - name: $name, - label: $attribute->label ?? Naming::headline($method->getName()), - type: $type, - method: $method->getName(), - ); - } - - return $fields; - } - - private function assertReturnType(string $class, ReflectionMethod $method): void - { - $type = $method->getReturnType(); - - if (! $type instanceof ReflectionType) { - throw InvalidBindingSourceException::forField($class, $method->getName(), 'declare its return type: string, int, float, bool, Stringable or null.'); - } - - $types = match (true) { - $type instanceof ReflectionUnionType => $type->getTypes(), - $type instanceof ReflectionIntersectionType => [$type], - default => [$type], - }; - - foreach ($types as $member) { - if (! $member instanceof ReflectionNamedType || ! $this->isAllowedReturn($member)) { - throw InvalidBindingSourceException::forField($class, $method->getName(), sprintf( - 'it returns %s; a field returns string, int, float, bool, Stringable or null.', - (string) $type - )); - } - } - } - - private function isAllowedReturn(ReflectionNamedType $type): bool - { - $name = $type->getName(); - - return in_array($name, self::SCALAR_RETURNS, true) || is_a($name, Stringable::class, true); - } -} diff --git a/src/BlockBinding/Application/Services/BindingSourceRegistry.php b/src/BlockBinding/Application/Services/BindingSourceRegistry.php deleted file mode 100644 index 71c25592..00000000 --- a/src/BlockBinding/Application/Services/BindingSourceRegistry.php +++ /dev/null @@ -1,46 +0,0 @@ - - */ - private array $sources = []; - - /** - * @throws InvalidBindingSourceException When another class already declares the name - */ - public function add(BindingSource $source): void - { - $existing = $this->sources[$source->name] ?? null; - - if ($existing instanceof BindingSource && $existing->class !== $source->class) { - throw InvalidBindingSourceException::duplicateName($source->name, $existing->class, $source->class); - } - - $this->sources[$source->name] = $source; - } - - public function find(string $name): ?BindingSource - { - return $this->sources[$name] ?? null; - } - - /** - * @return list - */ - public function all(): array - { - return array_values($this->sources); - } -} diff --git a/src/BlockBinding/Domain/Contracts/BindingEventsInterface.php b/src/BlockBinding/Domain/Contracts/BindingEventsInterface.php deleted file mode 100644 index de2bc567..00000000 --- a/src/BlockBinding/Domain/Contracts/BindingEventsInterface.php +++ /dev/null @@ -1,21 +0,0 @@ - $parameters Values given by parameter name or by class name - */ - public function call(object $instance, string $method, array $parameters): mixed; -} diff --git a/src/BlockBinding/Domain/Contracts/PostFinderInterface.php b/src/BlockBinding/Domain/Contracts/PostFinderInterface.php deleted file mode 100644 index 21292293..00000000 --- a/src/BlockBinding/Domain/Contracts/PostFinderInterface.php +++ /dev/null @@ -1,18 +0,0 @@ - $args Arguments of the binding, `field` included - * @param string $attribute The bound attribute: `content`, `url`, `alt`… - * @param int|null $postId Post of the block context - * @param string|null $postType Post type of the block context - * @param int|null $termId Term of the block context - * @param string|null $taxonomy Taxonomy of the block context - * @param \WP_Block|null $block The block, for advanced cases - */ - public function __construct( - public readonly array $args = [], - public readonly string $attribute = '', - public readonly ?int $postId = null, - public readonly ?string $postType = null, - public readonly ?int $termId = null, - public readonly ?string $taxonomy = null, - public readonly ?\WP_Block $block = null, - private readonly ?MetaAccessor $metaAccessor = null, - private readonly ?PostFinderInterface $postFinder = null, - ) {} - - /** - * An argument of the binding: `"args": {"field": "date", "format": "j F Y"}`. - */ - public function arg(string $name, mixed $default = null): mixed - { - return $this->args[$name] ?? $default; - } - - /** - * The post of the block context, as a model (the class bound to its post type, when - * there is one; in a Pollora application, the framework's Post model). - */ - public function post(): ?Post - { - $this->post ??= $this->postId === null ? false : (($this->postFinder instanceof PostFinderInterface ? $this->postFinder->find($this->postId) : Post::query()->find($this->postId)) ?? false); - - return $this->post ?: null; - } - - /** - * The typed meta a class declares, on the object of the block context that - * carries them: the post, the term, the post's author. - * - * @param class-string $class A class declaring `#[Meta]` properties - */ - public function meta(string $class): ?MetaRecord - { - $accessor = $this->metaAccessor ?? throw new LogicException('Typed meta are not available.'); - $objectId = $this->objectIdFor($accessor->schema($class)->objectType); - - return $objectId === null ? null : $accessor->of($class, $objectId); - } - - private function objectIdFor(MetaObjectType $objectType): ?int - { - return match ($objectType) { - MetaObjectType::Post => $this->postId, - MetaObjectType::Term => $this->termId, - MetaObjectType::User => $this->authorId(), - MetaObjectType::Comment => isset($this->block?->context['commentId']) ? (int) $this->block->context['commentId'] : null, - }; - } - - private function authorId(): ?int - { - $author = (int) $this->post()?->getAttribute('post_author'); - - return $author > 0 ? $author : null; - } -} diff --git a/src/BlockBinding/Domain/Models/BindingFieldDefinition.php b/src/BlockBinding/Domain/Models/BindingFieldDefinition.php deleted file mode 100644 index a8e0a65f..00000000 --- a/src/BlockBinding/Domain/Models/BindingFieldDefinition.php +++ /dev/null @@ -1,26 +0,0 @@ - $usesContext Block context the source reads - * @param list $postTypes Post types the source answers for; empty for all - * @param array $fields Fields by name; empty for an invokable source - */ - public function __construct( - public string $name, - public string $label, - public string $class, - public array $usesContext, - public array $postTypes, - public array $fields, - ) {} - - /** - * Whether `__invoke()` answers every call, the source having no field. - */ - public function isInvokable(): bool - { - return $this->fields === []; - } - - public function field(string $name): ?BindingFieldDefinition - { - return $this->fields[$name] ?? null; - } - - /** - * Whether the source answers for a post of this type. - */ - public function answersFor(?string $postType): bool - { - return $this->postTypes === [] || ($postType !== null && in_array($postType, $this->postTypes, true)); - } -} diff --git a/src/BlockBinding/Infrastructure/Adapters/ReflectionMethodInvoker.php b/src/BlockBinding/Infrastructure/Adapters/ReflectionMethodInvoker.php deleted file mode 100644 index 6f121ecd..00000000 --- a/src/BlockBinding/Infrastructure/Adapters/ReflectionMethodInvoker.php +++ /dev/null @@ -1,68 +0,0 @@ -getParameters() as $parameter) { - $type = $parameter->getType(); - $class = $type instanceof ReflectionNamedType && ! $type->isBuiltin() ? $type->getName() : null; - - $arguments[] = match (true) { - array_key_exists($parameter->getName(), $parameters) => $parameters[$parameter->getName()], - $class !== null && array_key_exists($class, $parameters) => $parameters[$class], - $class !== null && ($service = $this->service($class)) !== null => $service, - $parameter->isDefaultValueAvailable() => $parameter->getDefaultValue(), - $type?->allowsNull() === true => null, - default => throw new InvalidArgumentException(sprintf('Unable to resolve the parameter $%s of %s::%s().', $parameter->getName(), $instance::class, $method)), - }; - } - - return $reflection->invokeArgs($instance, $arguments); - } - - /** - * A service the container gives, or an instance of a class that needs - * nothing to be built. - */ - private function service(string $class): ?object - { - if ($this->container?->has($class) === true) { - return $this->container->get($class); - } - - try { - return $this->container?->get($class); - } catch (ContainerExceptionInterface) { - // not a service of this container: a class with no required argument is built below - } - - $constructor = class_exists($class) ? (new ReflectionClass($class))->getConstructor() : null; - - return class_exists($class) && ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) ? new $class : null; - } -} diff --git a/src/BlockBinding/Infrastructure/Adapters/WordPressBindingEditorScript.php b/src/BlockBinding/Infrastructure/Adapters/WordPressBindingEditorScript.php deleted file mode 100644 index 33e84afe..00000000 --- a/src/BlockBinding/Infrastructure/Adapters/WordPressBindingEditorScript.php +++ /dev/null @@ -1,45 +0,0 @@ -scriptPath) ? file_get_contents($this->scriptPath) : false; - - if ($script === false) { - return; - } - - \wp_register_script(self::HANDLE, false, self::DEPENDENCIES, null, true); - \wp_add_inline_script(self::HANDLE, 'window.polloraBlockBindings = '.\wp_json_encode($this->data->toArray(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES).';', 'before'); - \wp_add_inline_script(self::HANDLE, $script); - \wp_enqueue_script(self::HANDLE); - } -} diff --git a/src/BlockBinding/Infrastructure/Adapters/WordPressBindingRegistry.php b/src/BlockBinding/Infrastructure/Adapters/WordPressBindingRegistry.php deleted file mode 100644 index dc0491c3..00000000 --- a/src/BlockBinding/Infrastructure/Adapters/WordPressBindingRegistry.php +++ /dev/null @@ -1,46 +0,0 @@ - 0) { - $this->registerNow($source); - - return; - } - - $this->action->add('init', fn () => $this->registerNow($source)); - } - - private function registerNow(BindingSource $source): void - { - if (\get_block_bindings_source($source->name) !== null) { - return; - } - - \register_block_bindings_source($source->name, [ - 'label' => $source->label, - 'uses_context' => $source->usesContext, - 'get_value_callback' => fn (array $args, \WP_Block $block, string $attribute): mixed => $this->resolver->resolve($source, $args, $block, $attribute), - ]); - } -} diff --git a/src/BlockBinding/Infrastructure/Adapters/WordPressContentVisibility.php b/src/BlockBinding/Infrastructure/Adapters/WordPressContentVisibility.php deleted file mode 100644 index d46a81de..00000000 --- a/src/BlockBinding/Infrastructure/Adapters/WordPressContentVisibility.php +++ /dev/null @@ -1,37 +0,0 @@ -publicly_queryable) || \current_user_can('read'); - } -} diff --git a/src/BlockBinding/Infrastructure/Adapters/WordPressValuePresenter.php b/src/BlockBinding/Infrastructure/Adapters/WordPressValuePresenter.php deleted file mode 100644 index 79d13d39..00000000 --- a/src/BlockBinding/Infrastructure/Adapters/WordPressValuePresenter.php +++ /dev/null @@ -1,33 +0,0 @@ - $options The options a project lists as bindable (`block-bindings.options`) - */ - public function __construct( - private BindingSourceRegistry $sources, - private MetaSchemaRepository $schemas, - private array $options, - ) {} - - /** - * @return array{route: string, sources: array>} - */ - public function toArray(): array - { - $sources = []; - - foreach ($this->sources->all() as $source) { - $sources[$source->name] = match ($source->name) { - 'pollora/post-meta' => ['kind' => 'meta', 'subtype' => 'postType', 'fields' => $this->metaFields(MetaObjectType::Post, TypedMetaReader::exposedInRest(...))], - 'pollora/term-meta' => ['kind' => 'meta', 'subtype' => 'taxonomy', 'fields' => $this->metaFields(MetaObjectType::Term, TypedMetaReader::exposedInRest(...))], - 'pollora/author-meta' => ['kind' => 'meta', 'subtype' => null, 'fields' => $this->metaFields(MetaObjectType::User, static fn (MetaDefinition $definition): bool => $definition->public)], - 'pollora/option' => ['kind' => 'fields', 'postTypes' => [], 'fields' => $this->optionFields()], - default => ['kind' => 'fields', 'postTypes' => $source->postTypes, 'fields' => $this->sourceFields($source)], - }; - } - - return ['route' => self::REST_NAMESPACE.self::REST_ROUTE, 'sources' => $sources]; - } - - /** - * @return list, type: string}> - */ - private function sourceFields(BindingSource $source): array - { - $fields = []; - - foreach ($source->fields as $field) { - $fields[] = ['label' => $field->label, 'args' => ['field' => $field->name], 'type' => 'string']; - } - - return $fields; - } - - /** - * The meta a source may show, by subtype ('' for every object of the type). - * - * @param \Closure(MetaDefinition): bool $readable - * @return array, type: string}>> - */ - private function metaFields(MetaObjectType $objectType, \Closure $readable): array - { - $fields = []; - - foreach ($this->schemas->all() as $schema) { - if ($schema->objectType !== $objectType) { - continue; - } - - foreach ($schema->definitions as $definition) { - if (! $readable($definition) || $definition->valueType === MetaValueType::DataObject) { - continue; - } - - $label = $definition->label ?? Naming::headline($definition->property); - - foreach ($schema->subtypes === [] ? [''] : $schema->subtypes as $subtype) { - $fields[$subtype][] = ['label' => $label, 'args' => ['key' => $definition->key], 'type' => 'string']; - - if ($definition->media) { - $fields[$subtype][] = ['label' => $label, 'args' => ['key' => $definition->key], 'type' => 'number']; - } - } - } - } - - return $fields; - } - - /** - * @return list, type: string}> - */ - private function optionFields(): array - { - $fields = []; - - foreach ($this->options as $name) { - if (is_string($name)) { - $fields[] = ['label' => Naming::headline($name), 'args' => ['name' => $name], 'type' => 'string']; - } - } - - return $fields; - } -} diff --git a/src/BlockBinding/Infrastructure/Services/BindingFormatter.php b/src/BlockBinding/Infrastructure/Services/BindingFormatter.php deleted file mode 100644 index 35f54238..00000000 --- a/src/BlockBinding/Infrastructure/Services/BindingFormatter.php +++ /dev/null @@ -1,149 +0,0 @@ - $args Arguments of the binding - */ - public function format(MetaDefinition $definition, mixed $value, array $args, string $attribute): string|int|float|bool|null - { - if (in_array($value, [null, '', []], true)) { - return $this->fallback($args); - } - - if (($args['format'] ?? null) === 'raw') { - return match (true) { - is_scalar($value) => $value, - $value instanceof BackedEnum => $value->value, - $value instanceof DateTimeInterface => $value->format(DateTimeInterface::ATOM), - default => null, - }; - } - - if ($definition->media && is_int($value)) { - return $this->media($value, $args, $attribute) ?? $this->fallback($args); - } - - return match ($definition->valueType) { - MetaValueType::ArrayOf => $this->list($definition, (array) $value, $args, $attribute), - MetaValueType::DataObject => null, - default => $this->scalar($definition, $value, $args), - }; - } - - /** - * @param array $args - */ - private function scalar(MetaDefinition $definition, mixed $value, array $args): ?string - { - return match (true) { - is_bool($value) => $this->presenter->boolean($value, $this->stringArg($args, 'true'), $this->stringArg($args, 'false')), - is_int($value), is_float($value) => \number_format_i18n($value, $this->decimals($definition, $value, $args)), - $value instanceof DateTimeInterface => \wp_date($this->stringArg($args, 'format') ?? (string) \get_option('date_format'), $value->getTimestamp()) ?: null, - $value instanceof BackedEnum => method_exists($value, 'label') ? (string) $value->label() : (string) $value->value, - is_string($value) => $value, - default => null, - }; - } - - /** - * The items of an array, each formatted, as a sentence: "rock, jazz and blues". - * - * @param array $values - * @param array $args - */ - private function list(MetaDefinition $definition, array $values, array $args, string $attribute): ?string - { - $items = []; - - foreach ($values as $value) { - $item = $value === null ? null : $this->format($definition->item(), $value, [...$args, 'fallback' => null], $attribute); - - if ($item !== null && $item !== '') { - $items[] = (string) $item; - } - } - - return $items === [] ? $this->fallback($args) : \wp_sprintf('%l', $items); - } - - /** - * An attachment, as the bound attribute needs it. - * - * @param array $args - */ - private function media(int $attachmentId, array $args, string $attribute): string|int|null - { - if (\get_post_type($attachmentId) !== 'attachment') { - return null; - } - - $value = match ($attribute) { - 'id' => $attachmentId, - 'alt' => (string) \get_post_meta($attachmentId, '_wp_attachment_image_alt', true), - 'title' => \get_the_title($attachmentId), - 'caption' => (string) \wp_get_attachment_caption($attachmentId), - default => $this->presenter->url((string) (\wp_get_attachment_image_url($attachmentId, $this->stringArg($args, 'size') ?? 'full') ?: \wp_get_attachment_url($attachmentId))), - }; - - return $value === '' ? null : $value; - } - - /** - * @param array $args - */ - private function decimals(MetaDefinition $definition, int|float $value, array $args): int - { - if (is_numeric($args['decimals'] ?? null)) { - return max(0, (int) $args['decimals']); - } - - if ($definition->valueType === MetaValueType::Integer || is_int($value)) { - return 0; - } - - $fraction = strrchr((string) $value, '.'); - - return $fraction === false ? 0 : strlen($fraction) - 1; - } - - /** - * @param array $args - */ - private function fallback(array $args): ?string - { - return $this->stringArg($args, 'fallback'); - } - - /** - * @param array $args - */ - private function stringArg(array $args, string $name): ?string - { - return is_scalar($args[$name] ?? null) ? (string) $args[$name] : null; - } -} diff --git a/src/BlockBinding/Infrastructure/Services/BlockBindingDiscovery.php b/src/BlockBinding/Infrastructure/Services/BlockBindingDiscovery.php deleted file mode 100644 index 54a29cb4..00000000 --- a/src/BlockBinding/Infrastructure/Services/BlockBindingDiscovery.php +++ /dev/null @@ -1,72 +0,0 @@ -isAbstract) { - return; - } - - foreach ($structure->attributes as $attribute) { - if ($attribute->class === BlockBinding::class) { - $this->getItems()->add($location, ['class' => $structure->namespace.'\\'.$structure->name]); - - return; - } - } - } - - public function apply(): void - { - foreach ($this->getItems() as $item) { - /** @var class-string $class */ - $class = $item['class']; - - try { - $source = $this->builder->build($class); - $this->sources->add($source); - $this->registry->register($source); - } catch (\Throwable $throwable) { - $this->logger?->error(sprintf('Failed to register the block binding source %s: %s', $class, $throwable->getMessage()), ['exception' => $throwable]); - } - } - } - - public function getIdentifier(): string - { - return 'block_bindings'; - } -} diff --git a/src/BlockBinding/Infrastructure/Sources/AuthorMetaSource.php b/src/BlockBinding/Infrastructure/Sources/AuthorMetaSource.php deleted file mode 100644 index 51b9e089..00000000 --- a/src/BlockBinding/Infrastructure/Sources/AuthorMetaSource.php +++ /dev/null @@ -1,46 +0,0 @@ -arg('key'); - - if (! is_string($key) || $key === '' || $context->postId === null) { - return null; - } - - $authorId = (int) \get_post_field('post_author', $context->postId); - - if ($authorId <= 0) { - return null; - } - - $meta = $this->reader->read(MetaObjectType::User, null, $authorId, $key, static fn (MetaDefinition $definition): bool => $definition->public); - - return $meta === null ? null : $this->formatter->format($meta[0], $meta[1], $context->args, $context->attribute); - } -} diff --git a/src/BlockBinding/Infrastructure/Sources/OptionSource.php b/src/BlockBinding/Infrastructure/Sources/OptionSource.php deleted file mode 100644 index 40b1e391..00000000 --- a/src/BlockBinding/Infrastructure/Sources/OptionSource.php +++ /dev/null @@ -1,40 +0,0 @@ - $options The options a project lists as bindable (`block-bindings.options`) - */ - public function __construct( - private array $options, - ) {} - - public function __invoke(BindingContext $context): ?string - { - $name = $context->arg('name'); - $fallback = $context->arg('fallback'); - $fallback = is_scalar($fallback) ? (string) $fallback : null; - - if (! is_string($name) || ! in_array($name, $this->options, true)) { - return null; - } - - $value = \get_option($name, null); - - return is_scalar($value) && (string) $value !== '' ? (string) $value : $fallback; - } -} diff --git a/src/BlockBinding/Infrastructure/Sources/PostMetaSource.php b/src/BlockBinding/Infrastructure/Sources/PostMetaSource.php deleted file mode 100644 index 5684c6ed..00000000 --- a/src/BlockBinding/Infrastructure/Sources/PostMetaSource.php +++ /dev/null @@ -1,40 +0,0 @@ -arg('key'); - - if (! is_string($key) || $key === '' || $context->postId === null) { - return null; - } - - $postType = $context->postType ?? (\get_post_type($context->postId) ?: null); - $meta = $this->reader->read(MetaObjectType::Post, $postType, $context->postId, $key, TypedMetaReader::exposedInRest(...)); - - return $meta === null ? null : $this->formatter->format($meta[0], $meta[1], $context->args, $context->attribute); - } -} diff --git a/src/BlockBinding/Infrastructure/Sources/TermMetaSource.php b/src/BlockBinding/Infrastructure/Sources/TermMetaSource.php deleted file mode 100644 index 313fce6d..00000000 --- a/src/BlockBinding/Infrastructure/Sources/TermMetaSource.php +++ /dev/null @@ -1,61 +0,0 @@ -arg('key'); - [$termId, $taxonomy] = $this->term($context); - - if (! is_string($key) || $key === '' || $termId === null || $taxonomy === null) { - return null; - } - - $meta = $this->reader->read(MetaObjectType::Term, $taxonomy, $termId, $key, TypedMetaReader::exposedInRest(...)); - - return $meta === null ? null : $this->formatter->format($meta[0], $meta[1], $context->args, $context->attribute); - } - - /** - * @return array{0: int|null, 1: string|null} - */ - private function term(BindingContext $context): array - { - if ($context->termId !== null && $context->taxonomy !== null) { - return [$context->termId, $context->taxonomy]; - } - - $queried = \get_queried_object(); - - if (! $queried instanceof \WP_Term || ! $this->visibility->canShowTerm($queried->term_id, $queried->taxonomy)) { - return [null, null]; - } - - return [$queried->term_id, $queried->taxonomy]; - } -} diff --git a/src/BlockBinding/Infrastructure/Sources/TypedMetaReader.php b/src/BlockBinding/Infrastructure/Sources/TypedMetaReader.php deleted file mode 100644 index 5dcb9a5d..00000000 --- a/src/BlockBinding/Infrastructure/Sources/TypedMetaReader.php +++ /dev/null @@ -1,55 +0,0 @@ -schemas->forObject($objectType, $subtype) as $schema) { - $definition = $schema->find($key); - - if (! $definition instanceof MetaDefinition) { - continue; - } - - if (! $readable($definition)) { - return null; - } - - return [$definition, $this->accessor->record($schema, $objectId)->get($definition->property)]; - } - - return null; - } - - /** - * The rule of `core/post-meta`: a meta exposed in REST, under a key that is not protected. - */ - public static function exposedInRest(MetaDefinition $definition): bool - { - return $definition->showInRest && ! $definition->isProtected(); - } -} diff --git a/src/BlockBinding/UI/Http/ResolveBindingsController.php b/src/BlockBinding/UI/Http/ResolveBindingsController.php deleted file mode 100644 index 589eff96..00000000 --- a/src/BlockBinding/UI/Http/ResolveBindingsController.php +++ /dev/null @@ -1,128 +0,0 @@ - 'POST', - 'callback' => $this->handle(...), - 'permission_callback' => $this->canPreview(...), - 'args' => [ - 'context' => ['type' => 'object', 'default' => []], - 'bindings' => ['type' => 'array', 'required' => true, 'maxItems' => self::MAX_BINDINGS], - ], - ]); - } - - public function canPreview(\WP_REST_Request $request): bool - { - $postId = $this->context($request)['postId'] ?? null; - - return is_int($postId) ? \current_user_can('edit_post', $postId) : \current_user_can('edit_posts'); - } - - /** - * @return array{values: array} - */ - public function handle(\WP_REST_Request $request): array - { - $context = $this->context($request); - $values = []; - - // The page formats dates and numbers in the site's language; REST runs in the user's - $switched = \switch_to_locale(\get_locale()); - - foreach ((array) $request->get_param('bindings') as $binding) { - if (! is_array($binding) || ! is_scalar($binding['key'] ?? null)) { - continue; - } - - $source = is_string($binding['source'] ?? null) ? $this->sources->find($binding['source']) : null; - $args = is_array($binding['args'] ?? null) ? $binding['args'] : []; - $attribute = is_string($binding['attribute'] ?? null) ? $binding['attribute'] : 'content'; - - $values[(string) $binding['key']] = $source instanceof BindingSource - ? $this->resolver->preview($source, $args, $context, $attribute, $this->attributeSource($binding['block'] ?? null, $attribute)) - : null; - } - - if ($switched) { - \restore_previous_locale(); - } - - return ['values' => $values]; - } - - /** - * The `source` of an attribute in a registered block type, which decides its escaping. - */ - private function attributeSource(mixed $blockName, string $attribute): ?string - { - if (! is_string($blockName)) { - return null; - } - - $source = \WP_Block_Type_Registry::get_instance()->get_registered($blockName)?->attributes[$attribute]['source'] ?? null; - - return is_string($source) ? $source : null; - } - - /** - * The block context the editor sent, reduced to what the sources read. - * - * @return array{postId?: int, postType?: string, termId?: int, taxonomy?: string} - */ - private function context(\WP_REST_Request $request): array - { - $sent = (array) $request->get_param('context'); - $context = []; - - foreach (['postId', 'termId'] as $name) { - if (is_numeric($sent[$name] ?? null) && (int) $sent[$name] > 0) { - $context[$name] = (int) $sent[$name]; - } - } - - foreach (['postType', 'taxonomy'] as $name) { - if (is_string($sent[$name] ?? null) && $sent[$name] !== '') { - $context[$name] = \sanitize_key($sent[$name]); - } - } - - return $context; - } -} diff --git a/tests/Unit/BlockBinding/BindingContextTest.php b/tests/Unit/BlockBinding/BindingContextTest.php deleted file mode 100644 index 79226a3c..00000000 --- a/tests/Unit/BlockBinding/BindingContextTest.php +++ /dev/null @@ -1,61 +0,0 @@ - [7 => ['capacity' => '300']], 'term' => [3 => ['city' => 'Nantes']]]); - $this->accessor = new MetaAccessor(new MetaSchemaRepository, new MetaSchemaBuilder, $store, new MetaValueCaster); -}); - -it('gives the arguments of the binding', function (): void { - $context = new BindingContext(args: ['field' => 'date', 'format' => 'j F Y']); - - expect($context->arg('format'))->toBe('j F Y') - ->and($context->arg('size', 'large'))->toBe('large'); -}); - -it('reads typed meta on the object of the block context that carries them', function (): void { - $context = new BindingContext(postId: 7, postType: 'concert', termId: 3, taxonomy: 'venue', metaAccessor: $this->accessor); - - expect($context->meta(Concert::class)?->capacity)->toBe(300) - ->and($context->meta(VenueExtras::class)?->city)->toBe('Nantes'); -}); - -it('has no meta to give without the object', function (): void { - expect((new BindingContext(metaAccessor: $this->accessor))->meta(Concert::class))->toBeNull(); -}); - -it('loads the post of the block context through the finder it is given, once', function (): void { - $post = new Post; - $finder = new class($post) implements PostFinderInterface - { - public int $calls = 0; - - public function __construct(private readonly Post $post) {} - - public function find(int $postId): ?Post - { - $this->calls++; - - return $postId === 7 ? $this->post : null; - } - }; - $context = new BindingContext(postId: 7, postFinder: $finder); - - expect($context->post())->toBe($post) - ->and($context->post())->toBe($post) - ->and($finder->calls)->toBe(1) - ->and((new BindingContext(postId: 8, postFinder: $finder))->post())->toBeNull() - ->and((new BindingContext)->post())->toBeNull(); -}); diff --git a/tests/Unit/BlockBinding/BindingEditorDataTest.php b/tests/Unit/BlockBinding/BindingEditorDataTest.php deleted file mode 100644 index b746c5a0..00000000 --- a/tests/Unit/BlockBinding/BindingEditorDataTest.php +++ /dev/null @@ -1,52 +0,0 @@ -add((new BindingSourceBuilder)->build($class)); - } - - $schemas = new MetaSchemaRepository; - - foreach ([Concert::class, ArtistProfile::class, VenueExtras::class] as $class) { - $schemas->add((new MetaSchemaBuilder)->build($class)); - } - - $this->data = (new BindingEditorData($sources, $schemas, ['blogdescription']))->toArray(); -}); - -it('gives the editor the route and the fields of a #[BlockBinding] source', function (): void { - expect($this->data['route'])->toBe('pollora/v1/block-bindings/resolve') - ->and($this->data['sources']['acme/event']['postTypes'])->toBe(['event']) - ->and($this->data['sources']['acme/event']['fields'][0])->toBe(['label' => 'Remaining seats', 'args' => ['field' => 'remaining_seats'], 'type' => 'string']); -}); - -it('offers the post meta a binding may show, by post type', function (): void { - $fields = $this->data['sources']['pollora/post-meta']; - $keys = array_map(static fn (array $field): string => $field['args']['key'].':'.$field['type'], $fields['fields']['concert']); - - expect($fields['subtype'])->toBe('postType') - ->and($keys)->toContain('capacity:string', 'starts_at:string', 'cover_image_id:string', 'cover_image_id:number') - ->not->toContain('backstage_code:string', '_promoter_fee:string') - ->and($fields['fields']['concert'][0]['label'])->toBe('Starts At'); -}); - -it('offers the public user meta, the term meta and the listed options', function (): void { - expect(array_column(array_column($this->data['sources']['pollora/author-meta']['fields'][''], 'args'), 'key'))->toBe(['stage_name']) - ->and($this->data['sources']['pollora/term-meta']['fields']['venue'][0]['args'])->toBe(['key' => 'city']) - ->and($this->data['sources']['pollora/option']['fields'])->toBe([['label' => 'Blogdescription', 'args' => ['name' => 'blogdescription'], 'type' => 'string']]); -}); diff --git a/tests/Unit/BlockBinding/BindingFormatterTest.php b/tests/Unit/BlockBinding/BindingFormatterTest.php deleted file mode 100644 index 0c73075f..00000000 --- a/tests/Unit/BlockBinding/BindingFormatterTest.php +++ /dev/null @@ -1,89 +0,0 @@ -build(Concert::class)->definitions[$property]; -} - -/** - * @param array $args - */ -function formatConcert(string $property, mixed $value, array $args = [], string $attribute = 'content'): mixed -{ - return (new BindingFormatter(new FakePresenter))->format(concertMeta($property), $value, $args, $attribute); -} - -beforeEach(function (): void { - Functions\when('number_format_i18n')->alias(fn (float|int $number, int $decimals = 0): string => number_format((float) $number, $decimals, ',', "\u{202F}")); - Functions\when('wp_date')->alias(fn (string $format, int $timestamp): string => gmdate($format, $timestamp)); - Functions\when('get_option')->alias(fn (string $option, mixed $default = false): mixed => $option === 'date_format' ? 'd/m/Y' : $default); - Functions\when('wp_sprintf')->alias(fn (string $pattern, array $items): string => implode(', ', $items)); -}); - -it('formats a number with the site separators and its own decimals', function (): void { - expect(formatConcert('capacity', 1200))->toBe("1\u{202F}200") - ->and(formatConcert('price', 12.5))->toBe('12,5') - ->and(formatConcert('price', 12.5, ['decimals' => 2]))->toBe('12,50'); -}); - -it('formats a date in the site format, or the one given', function (): void { - $date = CarbonImmutable::parse('2026-11-14 09:00:00', 'UTC'); - - expect(formatConcert('startsAt', $date))->toBe('14/11/2026') - ->and(formatConcert('startsAt', $date, ['format' => 'Y-m-d H:i']))->toBe('2026-11-14 09:00'); -}); - -it('words a boolean, with the labels given', function (): void { - expect(formatConcert('soldOut', true))->toBe('Yes') - ->and(formatConcert('soldOut', false, ['true' => 'Complet', 'false' => 'Places disponibles']))->toBe('Places disponibles'); -}); - -it('shows an enum by its label', function (): void { - expect(formatConcert('status', ConcertStatus::Cancelled))->toBe('Cancelled') - ->and(formatConcert('status', ConcertStatus::Announced))->toBe('Coming soon'); -}); - -it('lists the items of an array', function (): void { - expect(formatConcert('genres', ['rock', 'jazz']))->toBe('rock, jazz'); -}); - -it('gives the raw value with format: raw', function (): void { - expect(formatConcert('capacity', 1200, ['format' => 'raw']))->toBe(1200) - ->and(formatConcert('status', ConcertStatus::Cancelled, ['format' => 'raw']))->toBe('cancelled') - ->and(formatConcert('startsAt', CarbonImmutable::parse('2026-11-14 09:00:00', 'UTC'), ['format' => 'raw']))->toBe('2026-11-14T09:00:00+00:00'); -}); - -it('shows the fallback for an empty meta, or keeps the block content', function (): void { - expect(formatConcert('startsAt', null, ['fallback' => 'Date to come']))->toBe('Date to come') - ->and(formatConcert('genres', []))->toBeNull(); -}); - -it('gives an attachment as the bound attribute needs it', function (): void { - Functions\when('get_post_type')->justReturn('attachment'); - Functions\when('wp_get_attachment_image_url')->alias(fn (int $id, string $size): string => "https://example.test/cover-{$size}.jpg"); - Functions\when('get_post_meta')->alias(fn (int $id, string $key): string => $key === '_wp_attachment_image_alt' ? 'The stage' : ''); - Functions\when('wp_get_attachment_caption')->justReturn(''); - - expect(formatConcert('coverImageId', 31, ['size' => 'large'], 'url'))->toBe('url(https://example.test/cover-large.jpg)') - ->and(formatConcert('coverImageId', 31, [], 'url'))->toBe('url(https://example.test/cover-full.jpg)') - ->and(formatConcert('coverImageId', 31, [], 'alt'))->toBe('The stage') - ->and(formatConcert('coverImageId', 31, [], 'id'))->toBe(31) - ->and(formatConcert('coverImageId', 31, [], 'caption'))->toBeNull(); -}); - -it('gives nothing for an ID that is not an attachment', function (): void { - Functions\when('get_post_type')->justReturn('post'); - - expect(formatConcert('coverImageId', 31, [], 'url'))->toBeNull(); -}); diff --git a/tests/Unit/BlockBinding/BindingReferenceInspectorTest.php b/tests/Unit/BlockBinding/BindingReferenceInspectorTest.php deleted file mode 100644 index 4eb9103a..00000000 --- a/tests/Unit/BlockBinding/BindingReferenceInspectorTest.php +++ /dev/null @@ -1,61 +0,0 @@ -add((new BindingSourceBuilder)->build($class)); - } - - $schemas = new MetaSchemaRepository; - - foreach ([Concert::class, ArtistProfile::class, VenueExtras::class] as $class) { - $schemas->add((new MetaSchemaBuilder)->build($class)); - } - - $this->inspector = new BindingReferenceInspector($sources, $schemas, ['blogdescription']); -}); - -it('accepts a binding that can show its value', function (string $source, array $args): void { - expect($this->inspector->problem($source, $args))->toBeNull(); -})->with([ - 'a field' => ['acme/event', ['field' => 'remaining_seats']], - 'an invokable source' => ['acme/echo', ['say' => 'hi']], - 'a post meta in REST' => ['pollora/post-meta', ['key' => 'capacity']], - 'a term meta in REST' => ['pollora/term-meta', ['key' => 'city']], - 'a public user meta' => ['pollora/author-meta', ['key' => 'stage_name']], - 'a listed option' => ['pollora/option', ['name' => 'blogdescription']], -]); - -it('says why a binding would leave its block unchanged', function (string $source, array $args, string $problem): void { - expect($this->inspector->problem($source, $args))->toBe($problem); -})->with([ - 'unknown field' => ['acme/event', ['field' => 'seats'], 'the field "seats" does not exist; acme/event has the fields "remaining_seats", "booking_url", "capacity", "sold_out", "summary", "nothing", "broken"'], - 'no field' => ['acme/event', [], 'no "field" argument; acme/event has the fields "remaining_seats", "booking_url", "capacity", "sold_out", "summary", "nothing", "broken"'], - 'undeclared meta' => ['pollora/post-meta', ['key' => 'nope'], 'no #[Meta] declares the post meta "nope"'], - 'meta out of REST' => ['pollora/post-meta', ['key' => 'backstage_code'], 'the meta "backstage_code" is never shown: it is not exposed in REST (showInRest: true) or its key is protected'], - 'protected meta' => ['pollora/post-meta', ['key' => '_promoter_fee'], 'the meta "_promoter_fee" is never shown: it is not exposed in REST (showInRest: true) or its key is protected'], - 'private user meta' => ['pollora/author-meta', ['key' => 'phone'], 'the meta "phone" is never shown: it is not marked #[Meta(public: true)]'], - 'no key' => ['pollora/term-meta', [], 'no "key" argument'], - 'unlisted option' => ['pollora/option', ['name' => 'admin_email'], 'the option "admin_email" is not listed in block-bindings.options'], -]); - -it('leaves the sources it does not know to WordPress', function (): void { - expect($this->inspector->knows('core/post-meta'))->toBeFalse() - ->and($this->inspector->problem('core/post-meta', ['key' => 'anything']))->toBeNull() - ->and($this->inspector->knows('acme/event'))->toBeTrue(); -}); diff --git a/tests/Unit/BlockBinding/BindingSourceBuilderTest.php b/tests/Unit/BlockBinding/BindingSourceBuilderTest.php deleted file mode 100644 index 3ddb988f..00000000 --- a/tests/Unit/BlockBinding/BindingSourceBuilderTest.php +++ /dev/null @@ -1,59 +0,0 @@ -build(EventBinding::class); - - expect($source->name)->toBe('acme/event') - ->and($source->label)->toBe('Event') - ->and($source->usesContext)->toBe(['postId', 'postType']) - ->and($source->postTypes)->toBe(['event']) - ->and(array_keys($source->fields))->toBe(['remaining_seats', 'booking_url', 'capacity', 'sold_out', 'summary', 'nothing', 'broken']) - ->and($source->field('remaining_seats')->label)->toBe('Remaining seats') - ->and($source->field('booking_url')->label)->toBe('Booking Url') - ->and($source->field('booking_url')->type)->toBe(BindingFieldType::Url) - ->and($source->field('capacity')->method)->toBe('seatCount') - ->and($source->isInvokable())->toBeFalse(); -}); - -it('builds an invokable source, labelled after its class', function (): void { - $source = (new BindingSourceBuilder)->build(InvokableBinding::class); - - expect($source->isInvokable())->toBeTrue() - ->and($source->label)->toBe('Invokable Binding') - ->and($source->usesContext)->toBe([]); -}); - -it('refuses a source WordPress would reject or leave empty', function (string $class, string $message): void { - expect(fn (): BindingSource => (new BindingSourceBuilder)->build($class)) - ->toThrow(InvalidBindingSourceException::class, $message); -})->with([ - 'no attribute' => [NotASource::class, 'has no #[BlockBinding] attribute'], - 'uppercase name' => [UppercaseName::class, 'must be "namespace/name"'], - 'no namespace' => [NoNamespace::class, 'must be "namespace/name"'], - 'no field' => [NoField::class, 'mark a public method #[BindingField], or define __invoke()'], - 'untyped field' => [UntypedField::class, 'UntypedField::title() cannot be registered: declare its return type'], - 'array field' => [ArrayField::class, 'it returns array'], - 'field twice' => [DuplicateField::class, 'the field "title" is already answered by heading()'], - 'unknown type' => [UnknownType::class, 'the type "video" is unknown'], - 'private field' => [PrivateField::class, 'a field must be a public, non-static method'], -]); diff --git a/tests/Unit/BlockBinding/BlockBindingDiscoveryTest.php b/tests/Unit/BlockBinding/BlockBindingDiscoveryTest.php deleted file mode 100644 index 7e5ee5f5..00000000 --- a/tests/Unit/BlockBinding/BlockBindingDiscoveryTest.php +++ /dev/null @@ -1,105 +0,0 @@ -registered = []; - Functions\when('did_action')->justReturn(1); - Functions\when('get_block_bindings_source')->alias(fn (string $name): ?object => isset($this->registered[$name]) ? (object) [] : null); - Functions\when('register_block_bindings_source')->alias(function (string $name, array $properties): bool { - $this->registered[$name] = $properties; - - return true; - }); - - $this->sources = new BindingSourceRegistry; - $this->logger = Mockery::mock(LoggerInterface::class); - $resolver = new BindingResolver(new Container, new FakeVisibility, new FakePresenter); - $this->discovery = new BlockBindingDiscovery(new BindingSourceBuilder, $this->sources, new WordPressBindingRegistry(Mockery::mock(Action::class), $resolver), $this->logger); - $this->location = new DiscoveryLocation('Tests\\', __DIR__); - - $this->discover = function (string ...$classes): void { - foreach ($classes as $class) { - $this->discovery->discover($this->location, DiscoveredClass::fromReflection(new ReflectionClass($class))); - } - }; -}); - -it('keeps the #[BlockBinding] classes only', function (): void { - ($this->discover)(EventBinding::class, SeatCounter::class, InvokableBinding::class); - - expect(array_column(iterator_to_array($this->discovery->getItems()), 'class'))->toBe([EventBinding::class, InvokableBinding::class]); -}); - -it('registers each source with WordPress, its label, context and one callback', function (): void { - ($this->discover)(EventBinding::class, InvokableBinding::class); - - $this->discovery->apply(); - - expect(array_keys($this->registered))->toBe(['acme/event', 'acme/echo']) - ->and($this->registered['acme/event']['label'])->toBe('Event') - ->and($this->registered['acme/event']['uses_context'])->toBe(['postId', 'postType']) - ->and(($this->registered['acme/echo']['get_value_callback'])(['say' => 'hello'], boundParagraph([]), 'content'))->toBe('hello') - ->and($this->sources->find('acme/event'))->not->toBeNull(); -}); - -it('waits for init when it has not run yet', function (): void { - Functions\when('did_action')->justReturn(0); - $action = Mockery::mock(Action::class); - $action->shouldReceive('add')->once()->with('init', Mockery::type(Closure::class))->andReturnUsing(function (string $hook, Closure $callback) use ($action): Action { - $callback(); - - return $action; - }); - - (new WordPressBindingRegistry($action, new BindingResolver(new Container, new FakeVisibility, new FakePresenter))) - ->register((new BindingSourceBuilder)->build(InvokableBinding::class)); - - expect(array_keys($this->registered))->toBe(['acme/echo']); -}); - -it('logs a source it cannot register and carries on', function (): void { - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/NoField: .*mark a public method/'), Mockery::type('array')); - ($this->discover)(NoField::class, InvokableBinding::class); - - $this->discovery->apply(); - - expect(array_keys($this->registered))->toBe(['acme/echo']); -}); - -it('refuses a source name another class declares', function (): void { - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/"acme\/echo" is declared twice/'), Mockery::type('array')); - $this->sources->add((new BindingSourceBuilder)->build(InvokableBinding::class)); - $clone = new #[BlockBinding('acme/echo')] class - { - public function __invoke(): string - { - return ''; - } - }; - $this->discovery->getItems()->add($this->location, ['class' => $clone::class]); - - $this->discovery->apply(); -}); diff --git a/tests/Unit/BlockBinding/PolloraSourcesTest.php b/tests/Unit/BlockBinding/PolloraSourcesTest.php deleted file mode 100644 index eb83ebed..00000000 --- a/tests/Unit/BlockBinding/PolloraSourcesTest.php +++ /dev/null @@ -1,92 +0,0 @@ -store = new ArrayMetaStore([ - 'post' => [7 => ['capacity' => '1200', 'backstage_code' => 'X42', '_promoter_fee' => '900']], - 'user' => [5 => ['stage_name' => 'The Hums', 'phone' => '0600000000']], - 'term' => [3 => ['city' => 'Lyon']], - ]); - $schemas = new MetaSchemaRepository; - - foreach ([Concert::class, ArtistProfile::class, VenueExtras::class] as $class) { - $schemas->add((new MetaSchemaBuilder)->build($class)); - } - - $this->reader = new TypedMetaReader($schemas, new MetaAccessor($schemas, new MetaSchemaBuilder, $this->store, new MetaValueCaster)); - $this->formatter = new BindingFormatter(new FakePresenter); - - Functions\when('number_format_i18n')->alias(fn (float|int $number, int $decimals = 0): string => number_format((float) $number, $decimals, ',', ' ')); -}); - -/** - * @param array $args - */ -function concertContext(array $args, ?int $termId = null, ?string $taxonomy = null): BindingContext -{ - return new BindingContext(args: $args, attribute: 'content', postId: 7, postType: 'concert', termId: $termId, taxonomy: $taxonomy); -} - -it('pollora/post-meta reads a declared meta exposed in REST, formatted', function (): void { - $source = new PostMetaSource($this->reader, $this->formatter); - - expect($source(concertContext(['key' => 'capacity'])))->toBe('1 200') - ->and($source(concertContext(['key' => 'capacity', 'format' => 'raw'])))->toBe(1200); -}); - -it('pollora/post-meta reads nothing hidden from REST, protected or undeclared', function (string $key): void { - expect((new PostMetaSource($this->reader, $this->formatter))(concertContext(['key' => $key])))->toBeNull(); -})->with(['not in REST' => ['backstage_code'], 'protected' => ['_promoter_fee'], 'undeclared' => ['unknown_key']]); - -it('pollora/term-meta reads the term of the block context', function (): void { - $source = new TermMetaSource($this->reader, $this->formatter, new FakeVisibility); - - expect($source(concertContext(['key' => 'city'], 3, 'venue')))->toBe('Lyon'); -}); - -it('pollora/term-meta falls back on the queried term, when the visitor can see it', function (): void { - $term = new WP_Term; - $term->term_id = 3; - $term->taxonomy = 'venue'; - - Functions\when('get_queried_object')->justReturn($term); - - expect((new TermMetaSource($this->reader, $this->formatter, new FakeVisibility))(concertContext(['key' => 'city'])))->toBe('Lyon') - ->and((new TermMetaSource($this->reader, $this->formatter, new FakeVisibility(hiddenTerms: [3])))(concertContext(['key' => 'city'])))->toBeNull(); -}); - -it('pollora/author-meta reads only the public meta of the author', function (): void { - Functions\when('get_post_field')->justReturn('5'); - $source = new AuthorMetaSource($this->reader, $this->formatter); - - expect($source(concertContext(['key' => 'stage_name'])))->toBe('The Hums') - ->and($source(concertContext(['key' => 'phone'])))->toBeNull(); -}); - -it('pollora/option reads only the options the project lists', function (): void { - Functions\when('get_option')->alias(fn (string $name): string => $name === 'blogdescription' ? 'Live music' : 'secret'); - $source = new OptionSource(['blogdescription']); - - expect($source(new BindingContext(args: ['name' => 'blogdescription'])))->toBe('Live music') - ->and($source(new BindingContext(args: ['name' => 'admin_email'])))->toBeNull(); -}); diff --git a/tests/Unit/BlockBinding/ResolveBindingsControllerTest.php b/tests/Unit/BlockBinding/ResolveBindingsControllerTest.php deleted file mode 100644 index 2f6e56f6..00000000 --- a/tests/Unit/BlockBinding/ResolveBindingsControllerTest.php +++ /dev/null @@ -1,89 +0,0 @@ - $params - */ -function previewRequest(array $params): WP_REST_Request -{ - $request = Mockery::mock(WP_REST_Request::class); - $request->shouldReceive('get_param')->andReturnUsing(fn (string $name): mixed => $params[$name] ?? null); - - return $request; -} - -beforeEach(function (): void { - Functions\when('get_locale')->justReturn('fr_FR'); - $this->locales = []; - Functions\when('switch_to_locale')->alias(function (string $locale): bool { - $this->locales[] = 'switch:'.$locale; - - return true; - }); - Functions\when('restore_previous_locale')->alias(function (): void { - $this->locales[] = 'restore'; - }); - Functions\when('sanitize_key')->alias(fn (string $key): string => strtolower((string) preg_replace('/[^a-z0-9_\-]/i', '', $key))); - - $sources = new BindingSourceRegistry; - $sources->add((new BindingSourceBuilder)->build(EventBinding::class)); - - $this->visibility = new FakeVisibility; - $this->controller = new ResolveBindingsController($sources, new BindingResolver(new Container, $this->visibility, new FakePresenter)); -}); - -it('answers the values of the bindings sent, by key', function (): void { - $response = $this->controller->handle(previewRequest([ - 'context' => ['postId' => '7', 'postType' => 'event', 'injected' => 'x'], - 'bindings' => [ - ['key' => 'a', 'source' => 'acme/event', 'args' => ['field' => 'remaining_seats'], 'attribute' => 'content'], - ['key' => 'b', 'source' => 'acme/event', 'args' => ['field' => 'booking_url'], 'attribute' => 'url'], - ['key' => 'c', 'source' => 'core/post-meta', 'args' => ['key' => 'secret']], - ['key' => 'd', 'source' => 'acme/event', 'args' => ['field' => 'sold_out']], - ['source' => 'acme/event'], - ], - ])); - - expect($response['values'])->toBe([ - 'a' => '14 seats & more', - 'b' => 'url(https://example.test/book/7)', - 'c' => null, - 'd' => 'Yes', - ]); -}); - -it('shows nothing of a post the editor may not read', function (): void { - $this->visibility->hiddenPosts = [7]; - - $response = $this->controller->handle(previewRequest([ - 'context' => ['postId' => 7, 'postType' => 'event'], - 'bindings' => [['key' => 'a', 'source' => 'acme/event', 'args' => ['field' => 'capacity']]], - ])); - - expect($response['values'])->toBe(['a' => null]); -}); - -it('lets preview only who can edit the post, or edit posts without one', function (): void { - Functions\when('current_user_can')->alias(fn (string $capability, mixed ...$args): bool => $capability === 'edit_post' && $args === [7]); - - expect($this->controller->canPreview(previewRequest(['context' => ['postId' => 7]])))->toBeTrue() - ->and($this->controller->canPreview(previewRequest(['context' => ['postId' => 8]])))->toBeFalse() - ->and($this->controller->canPreview(previewRequest(['context' => []])))->toBeFalse(); -}); - -it('formats the values in the site language, then gives the user theirs back', function (): void { - $this->controller->handle(previewRequest(['bindings' => []])); - - expect($this->locales)->toBe(['switch:fr_FR', 'restore']); -}); diff --git a/tests/Unit/BlockBinding/WordPressBindingEditorScriptTest.php b/tests/Unit/BlockBinding/WordPressBindingEditorScriptTest.php deleted file mode 100644 index 310eb9d3..00000000 --- a/tests/Unit/BlockBinding/WordPressBindingEditorScriptTest.php +++ /dev/null @@ -1,36 +0,0 @@ -alias(fn (mixed $data, int $flags = 0): string => json_encode($data, $flags)); - Functions\when('wp_register_script')->alias(function (string $handle, mixed $src, array $deps) use (&$calls): bool { - $calls[] = ['register', $handle, $src, $deps]; - - return true; - }); - Functions\when('wp_add_inline_script')->alias(function (string $handle, string $script, string $position = 'after') use (&$calls): bool { - $calls[] = ['inline', $position, $script]; - - return true; - }); - Functions\when('wp_enqueue_script')->alias(function (string $handle) use (&$calls): void { - $calls[] = ['enqueue', $handle]; - }); - - (new WordPressBindingEditorScript(new BindingEditorData(new BindingSourceRegistry, new MetaSchemaRepository, [])))->enqueue(); - - expect($calls[0])->toBe(['register', 'pollora-block-bindings', false, ['wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-data']]) - ->and($calls[1][1])->toBe('before') - ->and($calls[1][2])->toStartWith('window.polloraBlockBindings = {"route":"pollora/v1/block-bindings/resolve"') - ->and($calls[2][1])->toBe('after') - ->and($calls[2][2])->toStartWith('/**') - ->and($calls[3])->toBe(['enqueue', 'pollora-block-bindings']); -});