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
81 changes: 81 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,87 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `flexible_content` field support end-to-end: migration (`fields-migrate`)
lifts raw ACF `layouts` into a `layouts:` map keyed by layout name, and
generation (`fields-generate`) replays it back into ACF's raw `layouts`
list. Layout-level `label`/`min`/`max` and non-default `display`/`location`
round-trip verbatim; a layout's own `fields` recurse through the same
nesting machinery as an ordinary `group`/`repeater`, including
`flexible_content` nested inside another `flexible_content`'s layout.

### Changed

- **Breaking.** The `wp:` escape hatch no longer accepts properties that carry
a node's identity or its cross-references. `key`, `name`, `type`, `fields`,
`sub_fields`, `layouts` and `parent_repeater` are rejected inside any `wp:`
block — at the root group, on a field at any nesting depth, and on a
flexible_content layout. Enforced identically by the JSON Schema
(`wpOverlay` `$def`) and by the generator (`RESERVED_WP_PROPS`), since
`fields-lint` and `fields-generate` take different code paths.

`wp:` is merged last with highest precedence, so any property it could
override became a way to desynchronise the generated tree from what the
generator derived. Identity now has exactly one path. The sanctioned
alternatives are unchanged and validated: top-level `key:` (`^field_` /
`^layout_` / `^group_`), `name` derived from the YAML map key, the semantic
`type:` enum, and `wp.acf_type` as the ACF-type disambiguator.

No migration needed: verified against every committed definition in the
downstream fleet (174 across five projects) — none authors any reserved
property inside `wp:`. `wp.accordions` is deliberately untouched (38 uses in
that fleet); its residual overlay now excludes the same reserved set.

### Fixed

- Generator now enforces GLOBAL key uniqueness across an entire generated
field group — including flexible_content layouts and their sub-fields —
and throws a `GenerationValidationException` instead of silently emitting
two ACF fields that alias the same WordPress postmeta key. Underscore-joined
name-chain derivation could otherwise collide across unrelated
fields/layouts (e.g. layout `a_b` + field `c` vs. layout `a` + field `b_c`).
- `AcfJsonReader::readLayouts()` now throws on duplicate layout names instead
of silently overwriting the earlier layout (and its key) with no
diagnostic. `MigrationCompletenessAuditor` independently detects the same
duplicate in the raw ACF source — it previously could mask the collision
entirely when both duplicate layouts happened to share an identical
sub-field shape.
- Layout `display` (`block`/`table`/`row`) and `location` are now captured
verbatim by the migration reader (into the layout's `wp:` escape hatch when
non-default) and replayed by the generator, instead of the generator
hardcoding `display: block` / `location: null` unconditionally.
- `component.fields.schema.json`'s `layout` `$defs` now requires `label` (not
just `fields`) and constrains layout map keys to `^[a-z][a-z0-9_]*$` —
bringing it into parity with `parisek/acf-json-schema`'s
`field-flexible_content.schema.json`, which already rejected the empty
`label: ""` a label-less layout would generate.
- `acf-lint` (from `parisek/acf-json-schema`) is now wired into this
project's own test suite, validating every generated `acf.json` fixture
against the ecosystem's canonical ACF-shape validator — closing the gap
that let the `display`/`location` and schema-parity regressions above ship
unnoticed.
- `wp.key` no longer desynchronises `conditional_logic`. The sibling
name-to-key map was built from pre-overlay keys, so a field pinning its key
through `wp:` emitted a `conditional_logic` reference to a key present
nowhere in the output — silent, shipped, and simply never fired in the
editor.
- A root `wp: {fields: []}` no longer silently discards the entire generated
field tree, emitting an empty block with no error.
- `wp: {sub_fields: [...]}` / `wp: {layouts: [...]}` can no longer smuggle
unvalidated nodes one level deeper, and `wp: {parent_repeater: ...}` can no
longer overwrite a derived cross-reference.
- Sibling `name` collisions are caught by field shape rather than by an empty
name, so an ordinary field can no longer take the accordion carve-out.
- `wp.conditional_logic` — the migration reader's documented fallback for ACF
conditional logic too complex to express as `visible_when` — is now
validated rather than trusted: the canonical OR-of-AND-rules shape is
enforced, and every referenced key must resolve in the generated tree. A
dangling reference throws instead of shipping a broken editor condition.
- `fields-validate` and `fields-generate` no longer disagree. The
conditional-logic checks lived only in the generator's semantic pass, so a
definition could validate clean and then fail to generate.

## [0.2.1] - 2026-07-23

### Fixed
Expand Down
32 changes: 32 additions & 0 deletions bin/fields-validate
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

declare(strict_types=1);

use Parisek\DefinitionKit\Generator\FieldsGenerator;
use Parisek\DefinitionKit\Generator\GenerationValidationException;
use Parisek\DefinitionKit\Lint\KindLinter;
use Parisek\DefinitionKit\Schema\FieldsSchemaValidator;
use Symfony\Component\Yaml\Yaml;
Expand Down Expand Up @@ -50,6 +52,36 @@ foreach ($paths as $path) {
continue;
}

// `wp.conditional_logic` parity with `fields-generate` — see the
// `component.fields.schema.json` `wpOverlay` $def for the reserved-props
// half of this precedent (a pure JSON-Schema shape check both tools share
// via FieldsSchemaValidator above) and FieldsGenerator's own
// `assertConditionalLogicReferencesResolve()` docblock for why
// conditional_logic gets a validation gate instead of an outright ban.
//
// Shape validation (malformed conditional_logic) is a static check with
// no dependency on the assembled field tree. Reference RESOLUTION (does
// `field: X` name a key that actually exists) is NOT static — ACF field
// keys are derived through FieldsGenerator::deriveOrPinKey()'s full
// precedence chain (authored `key:`, the `wp.key` escape hatch, or a
// convention-derived dotted name-chain join), the exact same computation
// `siblingKeyMap()` / `buildField()` perform while assembling the tree.
// There is no lighter-weight way to know "what keys exist" than building
// that tree. Rather than re-implement key derivation a second time here
// (which is exactly the kind of split-brain this fix exists to close),
// this calls FieldsGenerator::generate() directly — a pure, side-effect-
// free computation (nothing is written to disk; only `fields-generate`
// persists the result via AcfJsonWriter/BlockJsonWriter). Both the shape
// check and the reference-resolution check therefore ride the same call.
try {
(new FieldsGenerator())->generate($definition, basename($path, '.yaml'), 0);
} catch (GenerationValidationException $e) {
$failed = true;
echo "FAIL {$path}\n";
echo " {$e->getMessage()}\n";
continue;
}

echo "OK {$path}\n";
foreach ($findings as $finding) {
// Warnings are reported but never flip the exit code.
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"require-dev": {
"ergebnis/composer-normalize": "^2.0",
"parisek/acf-json-schema": "^0.6",
"parisek/styleguide": "^1.7",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^11.0 || ^12.0"
Expand Down
12 changes: 12 additions & 0 deletions schemas/acf-defaults-baseline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ group:
repeater:
rows_per_page: 20

# flexible_content deliberately has NO baseline entry for
# `wpml_cf_preferences` (unlike group/repeater, which always carry the
# container value `3`) — a 4-project cross-corpus check
# (split-content/box-price-reference vs. ettin/pm-a/perfectaparasols)
# shows real ACF exports are genuinely inconsistent for this one field
# type: absent entirely on two components, present as a leaf-shaped `1`
# or `2` on three others, never observed as `3`. Generator\FieldReconstructor
# and Migration\AcfJsonReader special-case flexible_content to leave this
# prop alone entirely rather than force a default that would be wrong for
# roughly half the observed corpus — see both classes' own docblocks.
flexible_content: {}

accordion:
# entire pseudo-field type is dropped, not merged — kept here only for
# documentation. multi_expand/endpoint/open are ACF UI accordion knobs with
Expand Down
11 changes: 11 additions & 0 deletions schemas/acf-field-group.output.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@
"name": { "type": "string" },
"type": { "type": "string", "minLength": 1 },
"label": { "type": "string" },
"sub_fields": { "type": "array", "items": { "$ref": "#/$defs/field" } },
"layouts": { "type": "array", "items": { "$ref": "#/$defs/layout" } }
}
},
"layout": {
"type": "object",
"required": ["key", "name"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"name": { "type": "string" },
"label": { "type": "string" },
"sub_fields": { "type": "array", "items": { "$ref": "#/$defs/field" } }
}
}
Expand Down
40 changes: 36 additions & 4 deletions schemas/component.fields.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,17 @@
"description": { "type": "string" },
"weight": { "type": "integer" },
"responsive": { "type": "boolean" },
"wp": { "type": "object" },
"wp": { "$ref": "#/$defs/wpOverlay" },
"fields": { "$ref": "#/$defs/fieldMap" }
},
"$defs": {
"wpOverlay": {
"type": "object",
"propertyNames": {
"not": { "enum": ["key", "name", "type", "fields", "sub_fields", "layouts", "parent_repeater"] }
},
"description": "`wp:` is a fully open escape-hatch object merged with HIGHEST precedence over every derived/reconstructed prop. Two classes of prop are reserved: scalar identity (`key`/`name`/`type` — a field's WHOLE identity: ACF postmeta key, postmeta name, field-shape discriminator) and structural containers (`fields`/`sub_fields`/`layouts` — which carry the identity of NESTED nodes one level down, so letting `wp:` overwrite them is the exact same hazard one level removed) plus `parent_repeater` (ACF-computed structural metadata re-derived from nesting on every generation; it has no legitimate authoring path at all). Letting `wp:` override any of these gives identity two independent, silently-diverging paths (seven review rounds each found a new bug of this exact shape — round 7 found the ROOT-level `wp:` bag wasn't covered at all, plus the field/layout-level bypasses via the structural props). Use the sanctioned top-level prop instead: `key:` (pattern ^field_/^layout_/^group_) to pin a key, the YAML field-map key (or a layout's own `name:`) to control the name, the top-level `type:` enum to pick the field shape, and the field/layout's own `fields:`/`layouts:` map to author nested content — never `wp:`. `wp.conditional_logic` is deliberately NOT reserved here — see FieldsGenerator's own dangling-reference validation, which is the correctness gate for that prop instead."
},
"fieldMap": {
"type": "object",
"minProperties": 1,
Expand Down Expand Up @@ -73,7 +80,7 @@
"additionalProperties": false,
"properties": {
"type": {
"enum": ["text", "richtext", "number", "boolean", "select", "media", "link", "reference", "group", "repeater", "date"]
"enum": ["text", "richtext", "number", "boolean", "select", "media", "link", "reference", "group", "repeater", "flexible_content", "date"]
},
"role": { "enum": ["field", "query", "computed", "global"] },
"label": { "type": "string", "minLength": 1 },
Expand Down Expand Up @@ -105,8 +112,9 @@
"resolved_from": { "type": "array", "items": { "type": "string" } },
"source": { "type": "string" },
"demo": {},
"wp": { "type": "object" },
"fields": { "$ref": "#/$defs/fieldMap" }
"wp": { "$ref": "#/$defs/wpOverlay" },
"fields": { "$ref": "#/$defs/fieldMap" },
"layouts": { "$ref": "#/$defs/layoutMap" }
},
"allOf": [
{
Expand All @@ -116,8 +124,32 @@
{
"if": { "properties": { "type": { "enum": ["group", "repeater"] } }, "required": ["type"] },
"then": { "required": ["fields"] }
},
{
"if": { "properties": { "type": { "const": "flexible_content" } }, "required": ["type"] },
"then": { "required": ["layouts"] }
}
]
},
"layoutMap": {
"type": "object",
"minProperties": 1,
"propertyNames": { "pattern": "^[a-z][a-z0-9_]*$" },
"additionalProperties": { "$ref": "#/$defs/layout" }
},
"layout": {
"type": "object",
"required": ["fields", "label"],
"additionalProperties": false,
"properties": {
"label": { "type": "string", "minLength": 1 },
"key": { "type": "string", "pattern": "^layout_" },
"name": { "type": "string", "minLength": 1 },
"min": { "type": "integer", "minimum": 0 },
"max": { "type": "integer", "minimum": 0 },
"wp": { "$ref": "#/$defs/wpOverlay" },
"fields": { "$ref": "#/$defs/fieldMap" }
}
}
}
}
3 changes: 3 additions & 0 deletions schemas/constraint-sentinels.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ number:
repeater:
min: 0
max: 0
flexible_content:
min: 0
max: 0
file:
min_size: ''
max_size: ''
Expand Down
17 changes: 17 additions & 0 deletions src/Generator/AbstractTypeReverseMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public function reverse(array $semanticField): array
'date' => ['acfType' => 'date_picker', 'extra' => []],
'group' => ['acfType' => 'group', 'extra' => []],
'repeater' => $this->repeater($semanticField),
'flexible_content' => $this->flexibleContent($semanticField),
default => throw new \DomainException(sprintf(
"Unsupported abstract type '%s' — add a case to AbstractTypeReverseMapper::reverse().",
$type,
Expand Down Expand Up @@ -151,4 +152,20 @@ private function repeater(array $field): array
}
return ['acfType' => 'repeater', 'extra' => $extra];
}

/**
* Mirrors repeater() — flexible_content shares the identical
* add_label <-> button_label bijection.
*
* @param array<string,mixed> $field
* @return array{acfType: string, extra: array<string,mixed>}
*/
private function flexibleContent(array $field): array
{
$extra = [];
if (!empty($field['add_label'])) {
$extra['button_label'] = (string) $field['add_label'];
}
return ['acfType' => 'flexible_content', 'extra' => $extra];
}
}
27 changes: 22 additions & 5 deletions src/Generator/FieldReconstructor.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ final class FieldReconstructor
{
private const CONTAINER_ACF_TYPES = ['group', 'repeater'];

/**
* flexible_content's own `wpml_cf_preferences` is deliberately NEVER
* auto-reconstructed here — unlike group/repeater (always the
* container value `3`) or every leaf type (always 1/2), real ACF
* exports are genuinely inconsistent for this one field type (absent
* entirely on some components, a leaf-shaped 1/2 on others, never `3`
* — see acf-defaults-baseline.yaml's flexible_content comment for the
* corpus census). Whatever value (or absence) the source had is
* captured verbatim in the field's own `wp.wpml_cf_preferences` by
* Migration\AcfJsonReader and re-applied by
* Generator\FieldsGenerator's `wp:` overlay — this class simply never
* emits a default that would be wrong for roughly half the corpus.
*/
private const NO_AUTO_WPML_TYPES = ['flexible_content'];

public function __construct(
private readonly AbstractTypeReverseMapper $typeMapper = new AbstractTypeReverseMapper(),
private readonly VisibleWhenMapper $visibleWhenMapper = new VisibleWhenMapper(),
Expand All @@ -48,10 +63,12 @@ public function reconstruct(array $semanticField, array $nameKeyMap): array
$out['instructions'] = (string) ($semanticField['description'] ?? '');

$isContainer = in_array($acfType, self::CONTAINER_ACF_TYPES, true);
$out['wpml_cf_preferences'] = $this->wpmlMapper->toWpmlPreference(
$acfType,
!$isContainer && true === ($semanticField['translatable'] ?? false),
);
if (!in_array($acfType, self::NO_AUTO_WPML_TYPES, true)) {
$out['wpml_cf_preferences'] = $this->wpmlMapper->toWpmlPreference(
$acfType,
!$isContainer && true === ($semanticField['translatable'] ?? false),
);
}

if (isset($semanticField['maxlength'])) {
$out['maxlength'] = (int) $semanticField['maxlength'];
Expand All @@ -64,7 +81,7 @@ public function reconstruct(array $semanticField, array $nameKeyMap): array
}
}
}
if ('repeater' === $acfType) {
if (in_array($acfType, ['repeater', 'flexible_content'], true)) {
foreach (['min', 'max'] as $prop) {
if (isset($semanticField[$prop])) {
$out[$prop] = $semanticField[$prop];
Expand Down
Loading