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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,33 @@ match ($block->getIdentifier()) {
};
```

Each block class also carries `IDENTIFIER`, `COMPONENT` and `SLOTS` constants.
Each block class also carries `IDENTIFIER`, `COMPONENT` and `SLOTS` constants, plus one constant
per option of every content and config field that has a fixed set of them, named
`CONTENT_<FIELD>_<OPTION>` and `CONFIG_<FIELD>_<OPTION>`:

```php
use App\Flyo\Blocks\BlockHero;

/** @var BlockHero $block */
$modifier = match ($block->getContent()->size->value) {
BlockHero::CONTENT_SIZE_SMALL => 'hero--small', // 'small'
BlockHero::CONTENT_SIZE_MEDIUM => 'hero--medium', // 'medium'
BlockHero::CONTENT_SIZE_LARGE => 'hero--large', // 'large'
null => '',
};

if ($block->getConfig()->theme === BlockHero::CONFIG_THEME_DARK) {
// ...
}
```

A select field's constants are named after the field, not after its `value`. Names are upper snake
case: the field `background-color` with the option `darkBlue` gives
`CONTENT_BACKGROUND_COLOR_DARK_BLUE`, whose value is `'darkBlue'`, the option exactly as Flyo lists
it. PHPStan knows each constant's literal value, so it reports a `match` that misses an option. If
two options would get the same name, neither gets a constant and the generator prints a warning, so
a name never quietly starts to mean a different option. Only `flyo-generate-types` generates these
constants; the deprecated `flyo-generate-blocks` does not.

**Entities** narrow `getModel()`, the part of an entity response whose shape depends on its type:

Expand Down
38 changes: 38 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Upgrade

## From 3.7 to 3.8

### Generated types: constants for field options

`flyo-generate-types` gives every block class one constant per option of each content and config
field that has a fixed set of them, select fields included. A constant is named
`<CONTENT|CONFIG>_<FIELD>_<OPTION>` and holds the option exactly as Flyo lists it:

```diff
class BlockHero extends \Flyo\Model\Block
{
public const IDENTIFIER = 'hero';

public const COMPONENT = 'Hero';
+
+ public const CONTENT_SIZE_SMALL = 'small';
+ public const CONTENT_SIZE_MEDIUM = 'medium';
+ public const CONTENT_SIZE_LARGE = 'large';
+
+ public const CONFIG_THEME_LIGHT = 'light';
+ public const CONFIG_THEME_DARK = 'dark';
```

If two options would get the same name (`Small` and `small`), neither gets a constant and the
generator prints a warning. Numbering them instead would let a name silently move to another option
once one is added in Flyo.

This changes the generated file of every block with such a field, so `--check` reports them as out
of date until you regenerate once. The deprecated `flyo-generate-blocks` does not generate the
constants, and its output stays as it was.

### Generated types: an option containing `*/`

An option whose value contains `*/` closed the docblock the field's type is written into, so the
generated file did not parse. Such a field is now typed `string`, like a field with an option that
spans lines, and gets no constants. This applies to both commands, but only changes files that were
broken before.

## From 3.6 to 3.7

### Generated types: select fields keep their values
Expand Down
53 changes: 51 additions & 2 deletions lib/Generator/BlockKind.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* Blocks: one class per `x-schema-type: block` schema, narrowing \Flyo\Model\Block.
*
* The deprecated `flyo-generate-blocks` renders these same classes, so any change to their output
* shows up as drift in every consumer's `--check` and has to be deliberate.
* shows up as drift in every consumer's `--check` and has to be deliberate. That is why it plans
* them without the {@see EnumConstants}, which came later.
*/
final class BlockKind implements Kind
{
Expand All @@ -31,6 +32,21 @@ final class BlockKind implements Kind
/** Never a real slot: the server adds it as a bookkeeping flag. */
private const SLOT_FLAG = '_empty';

/**
* The block schema properties whose fields get {@see EnumConstants}: the two an editor fills
* in. `CONTENT_SIZE_SMALL` names an option of `content.size`, `CONFIG_SIZE_SMALL` one of
* `config.size`.
*/
private const ENUM_OBJECTS = ['content', 'config'];

/**
* @param bool $enumConstants name the options of every content and config field that has a
* fixed set of them; off for the deprecated flyo-generate-blocks, whose output stays as it was
*/
public function __construct(private readonly bool $enumConstants = true)
{
}

public function schemaType(): string
{
return 'block';
Expand Down Expand Up @@ -67,7 +83,14 @@ public function plan(Document $document, array $schemas, string $namespace, Warn
// Pass 2: plan.
$classes = [];
foreach ($schemas as $schemaKey => $schema) {
$classes[] = $this->classDef($schemaKey, $schema, $classNames[$schemaKey], $namespace, $types);
$classes[] = $this->classDef(
$schemaKey,
$schema,
$classNames[$schemaKey],
$namespace,
$types,
$warnings,
);
}

return $classes;
Expand All @@ -82,6 +105,7 @@ private function classDef(
string $className,
string $namespace,
TypeMapper $types,
Warnings $warnings,
): ClassDef {
$required = Schema::requiredKeys($schema);

Expand Down Expand Up @@ -115,6 +139,12 @@ private function classDef(
if ($slots !== []) {
$constants[] = new ConstantDef('SLOTS', $slots);
}
if ($this->enumConstants) {
$constants = [
...$constants,
...EnumConstants::plan($schemaKey, self::enumObjects($schema), $warnings),
];
}

return new ClassDef(
$className,
Expand All @@ -131,6 +161,25 @@ private function classDef(
);
}

/**
* @param array<string, mixed> $schema
* @return array<string, array<string, mixed>>
*/
private static function enumObjects(array $schema): array
{
$objects = [];

foreach (self::ENUM_OBJECTS as $property) {
$object = Schema::property($schema, $property);

if ($object !== null) {
$objects[$property] = $object;
}
}

return $objects;
}

/**
* The declared slot identifiers, in document order.
*
Expand Down
3 changes: 3 additions & 0 deletions lib/Generator/ConstantDef.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ final class ConstantDef
{
/**
* @param string|list<string> $value
* @param string|null $group constants that share a group are rendered without a blank line
* between them, like the options of one field
*/
public function __construct(
public readonly string $name,
public readonly string|array $value,
public readonly ?string $group = null,
) {
}
}
189 changes: 189 additions & 0 deletions lib/Generator/EnumConstants.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?php

declare(strict_types=1);

namespace Flyo\Generator;

/**
* Class constants naming the options of every field that has a fixed set of them.
*
* Each option becomes `<OBJECT>_<FIELD>_<OPTION>`: a block whose content has a `size` select with
* the options `small` and `large` gets `CONTENT_SIZE_SMALL = 'small'` and
* `CONTENT_SIZE_LARGE = 'large'`. The options are the field's own `enum`, or, for a select field,
* the `enum` of its `value`. `null` is never one: it means nothing is selected.
*
* The values are exactly the strings {@see TypeMapper} puts in the field's type, so comparing the
* field with a constant type-checks wherever comparing it with the literal does.
*/
final class EnumConstants
{
/** The property a select field keeps the selected option in. */
private const SELECT_VALUE = 'value';

/** Keywords {@see TypeMapper::type()} acts on before it ever looks at an `enum`. */
private const PRECEDING_KEYWORDS = ['$ref', 'oneOf', 'anyOf', 'allOf'];

private function __construct()
{
}

/**
* The constants for the fields of some object schemas of one typed schema.
*
* A name that would stand for two different options is not generated at all, rather than
* numbered: a number would silently move to another option once one is added in Flyo, while a
* missing constant fails loudly wherever it is used.
*
* @param string $schemaKey the typed schema, for warnings
* @param array<string, array<string, mixed>> $objects property => the object schema whose
* fields to scan, e.g. `content` => a block's content schema; the property names the prefix
* @return list<ConstantDef> in document order, grouped by field
*/
public static function plan(string $schemaKey, array $objects, Warnings $warnings): array
{
/** @var array<string, list<array{string, string}>> $claims name => [field path, option] */
$claims = [];

foreach ($objects as $property => $object) {
$fields = $object['properties'] ?? null;

if (!is_array($fields)) {
continue;
}

$prefix = Naming::constantSegment($property);

foreach ($fields as $field => $fieldSchema) {
// A numeric-looking JSON key arrives as an int.
$field = (string) $field;
$options = is_array($fieldSchema) ? self::options($fieldSchema) : [];

if ($options === []) {
continue;
}

$path = $property . '.' . $field;
$fieldSegment = Naming::constantSegment($field);

if ($fieldSegment === '') {
$warnings->add(sprintf(
'%s.%s: the field name has no letter or digit to name constants after, so its '
. 'options get none.',
$schemaKey,
$path,
));

continue;
}

foreach ($options as $option) {
$optionSegment = Naming::constantSegment($option);

if ($optionSegment === '') {
$warnings->add(sprintf(
'%s.%s: the option "%s" has no letter or digit to name a constant after, so '
. 'it gets none.',
$schemaKey,
$path,
$option,
));

continue;
}

$claims[$prefix . '_' . $fieldSegment . '_' . $optionSegment][] = [$path, $option];
}
}
}

$constants = [];

foreach ($claims as $name => $claimants) {
$options = array_values(array_unique(array_column($claimants, 1)));

if (count($options) > 1) {
$rendered = [];
foreach ($claimants as [$path, $option]) {
$rendered[] = sprintf('%s "%s"', $path, $option);
}

$warnings->add(sprintf(
'%s: %s would stand for more than one option (%s), so it is not generated. '
. 'Rename one of the options in Flyo to remove the ambiguity.',
$schemaKey,
$name,
implode(', ', $rendered),
));

continue;
}

$constants[] = new ConstantDef($name, $options[0], $claimants[0][0]);
}

return $constants;
}

/**
* The options of one field, or `[]` when it has no fixed set of them.
*
* @param array<string, mixed> $field
* @return list<string>
*/
private static function options(array $field): array
{
$options = self::literals($field);

if ($options !== null) {
return $options;
}

// A select field: an object whose `value` is one of the options, or null.
if (($field['type'] ?? null) !== 'object' || self::isDeferred($field)) {
return [];
}

$value = Schema::property($field, self::SELECT_VALUE);

return $value === null ? [] : (self::literals($value) ?? []);
}

/**
* The non-null members of a node's `enum`, or null unless TypeMapper types the node as a union
* of exactly those literals: a node typed `string`, or not typed at all.
*
* @param array<string, mixed> $node
* @return list<string>|null
*/
private static function literals(array $node): ?array
{
$type = $node['type'] ?? null;

if (($type !== null && $type !== 'string') || self::isDeferred($node)) {
return null;
}

$members = TypeMapper::enumMembers($node);

return $members === null ? null : array_values(array_filter(
$members,
static fn (?string $member): bool => $member !== null,
));
}

/**
* Whether TypeMapper types the node by something other than its own keywords.
*
* @param array<string, mixed> $node
*/
private static function isDeferred(array $node): bool
{
foreach (self::PRECEDING_KEYWORDS as $keyword) {
if (isset($node[$keyword])) {
return true;
}
}

return false;
}
}
Loading
Loading