diff --git a/src/Integrations/Divi/Design/ThemeOptionBook.php b/src/Integrations/Divi/Design/ThemeOptionBook.php new file mode 100644 index 0000000..32d2c26 --- /dev/null +++ b/src/Integrations/Divi/Design/ThemeOptionBook.php @@ -0,0 +1,80 @@ + $data */ + private function __construct(private array $data) + { + } + + /** @param array $raw Other keys, null and values of other types are dropped. */ + public static function fromDivi(array $raw): self + { + $data = []; + foreach (ThemeOptionSpec::KEYS as $key) { + $value = $raw[$key] ?? null; + if ($value === false || is_int($value) || is_float($value) || is_string($value)) { + $data[$key] = $value; + } + } + + return new self($data); + } + + /** @return array */ + public function data(): array + { + return $this->data; + } + + /** Hash of the canonical JSON; the key order is fixed, and 1, 1.0 and "1" differ. */ + public function rev(): string + { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). + $json = (string) json_encode($this->data, self::JSON_FLAGS); + + return Rev::of('theme_options' . "\0" . $json); + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->data); + } + + public function get(string $key): int|float|string|false|null + { + return $this->data[$key] ?? null; + } + + /** @throws InvalidArgumentException For a key outside the whitelist. */ + public function with(string $key, int|float|string|false $value): self + { + ThemeOptionSpec::definition($key); + + return self::fromDivi([...$this->data, $key => $value]); + } + + public function without(string $key): self + { + return self::fromDivi(array_diff_key($this->data, [$key => true])); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php b/tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php new file mode 100644 index 0000000..f036213 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php @@ -0,0 +1,115 @@ + The shape of get_option('et_divi') with the options of other areas around. */ + public static function stored(): array + { + return [ + 'accent_color' => '#2ea3f2', + 'body_font_size' => 16, + 'heading_font' => false, + 'logo' => 'https://example.test/logo.png', + 'body_font_height' => 1.7, + 'body_header_style' => 'uppercase', + 'body_font' => 'Lato', + ]; + } + + public function testOnlyWhitelistKeysStayInTheFixedOrderWithTheirRawTypes(): void + { + $book = ThemeOptionBook::fromDivi(self::stored()); + + self::assertSame([ + 'heading_font' => false, + 'body_font' => 'Lato', + 'body_font_size' => 16, + 'body_font_height' => 1.7, + 'body_header_style' => 'uppercase', + ], $book->data()); + self::assertSame(['heading_font', 'body_font', 'body_font_size', 'body_font_height', 'body_header_style'], + array_keys($book->data())); + } + + public function testNullAndValuesOfOtherTypesAreDropped(): void + { + $book = ThemeOptionBook::fromDivi([ + 'heading_font' => null, 'body_font' => ['Lato'], 'content_width' => true, 'row_padding' => 0, + 'gutter_width' => '3', 'section_padding' => 0.0, 'body_header_spacing' => new \stdClass(), + ]); + + self::assertSame(['gutter_width' => '3', 'section_padding' => 0.0, 'row_padding' => 0], $book->data()); + self::assertSame([], ThemeOptionBook::fromDivi([])->data()); + } + + public function testHasAndGetDistinguishAFalseValueFromAMissingKey(): void + { + $book = ThemeOptionBook::fromDivi(self::stored()); + + self::assertTrue($book->has('heading_font')); + self::assertFalse($book->get('heading_font')); + self::assertFalse($book->has('primary_nav_font')); + self::assertNull($book->get('primary_nav_font')); + self::assertFalse($book->has('logo')); + self::assertNull($book->get('logo')); + self::assertSame(16, $book->get('body_font_size')); + } + + public function testRevIgnoresTheOrderAndOtherKeysButNotValuesOrTheirTypes(): void + { + $book = ThemeOptionBook::fromDivi(self::stored()); + $shuffled = ThemeOptionBook::fromDivi(array_reverse(self::stored(), true) + ['phone_body_font_size' => null]); + + self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev()); + self::assertSame($book->rev(), $shuffled->rev()); + self::assertSame($book->rev(), ThemeOptionBook::fromDivi($book->data())->rev()); + self::assertNotSame($book->rev(), $book->with('body_font_size', 17)->rev()); + self::assertNotSame($book->rev(), $book->with('body_font_size', '16')->rev(), '16 versus "16"'); + self::assertNotSame($book->rev(), $book->with('heading_font', 'Lato')->rev(), 'false versus a font'); + self::assertNotSame($book->rev(), $book->without('body_font_size')->rev()); + self::assertNotSame( + ThemeOptionBook::fromDivi(['body_header_height' => 1])->rev(), + ThemeOptionBook::fromDivi(['body_header_height' => 1.0])->rev(), + '1 versus 1.0' + ); + self::assertNotSame($book->rev(), ThemeOptionBook::fromDivi([])->rev()); + } + + public function testWithAddsOrReplacesInPlaceOfTheFixedOrderAndLeavesTheOriginalAlone(): void + { + $book = ThemeOptionBook::fromDivi(self::stored()); + $changed = $book->with('primary_nav_font', 'Roboto')->with('body_font_size', 18); + + self::assertSame(['heading_font', 'body_font', 'primary_nav_font', 'body_font_size', 'body_font_height', + 'body_header_style'], array_keys($changed->data())); + self::assertSame(18, $changed->get('body_font_size')); + self::assertSame(16, $book->get('body_font_size')); + self::assertFalse($book->has('primary_nav_font')); + self::assertFalse($book->with('body_font', false)->get('body_font')); + } + + public function testWithRejectsAKeyOutsideTheWhitelist(): void + { + $this->expectException(InvalidArgumentException::class); + ThemeOptionBook::fromDivi([])->with('accent_color', '#fff'); + } + + public function testWithoutRemovesAKeyAndIgnoresAMissingOne(): void + { + $book = ThemeOptionBook::fromDivi(self::stored()); + $without = $book->without('heading_font'); + + self::assertFalse($without->has('heading_font')); + self::assertTrue($book->has('heading_font')); + self::assertSame($book->rev(), $book->without('row_padding')->rev()); + self::assertSame($book->data(), $book->without('logo')->data()); + } +}