diff --git a/web/landing/.gitignore b/web/landing/.gitignore index 45bd3e1bd..cd32ddf79 100644 --- a/web/landing/.gitignore +++ b/web/landing/.gitignore @@ -4,6 +4,8 @@ build/ vendor/ var/ public/assets +public/images/blog/social/ +public/images/work-shop/social/ public/pagefind public/*.xml !public/BingSiteAuth.xml \ No newline at end of file diff --git a/web/landing/config/bundles.php b/web/landing/config/bundles.php index b81b94cdf..672c1f05a 100644 --- a/web/landing/config/bundles.php +++ b/web/landing/config/bundles.php @@ -1,4 +1,6 @@ - ['all' => true], diff --git a/web/landing/config/preload.php b/web/landing/config/preload.php index 380c0921a..137ba873d 100644 --- a/web/landing/config/preload.php +++ b/web/landing/config/preload.php @@ -1,4 +1,6 @@ - type_string(), 'date' => type_string(), 'slug' => type_string(), + 'author' => type_string(), ])->assert($data); - return new self($data['title'], $data['description'], new DateTimeImmutable($data['date']), $data['slug']); + return new self( + $data['title'], + $data['description'], + new DateTimeImmutable($data['date']), + $data['slug'], + $data['author'], + ); + } + + public function authorAvatarUrl(int $size = 96): string + { + return sprintf('https://github.com/%s.png?size=%d', $this->author, $size); + } + + public function authorUrl(): string + { + return 'https://github.com/' . $this->author; } } diff --git a/web/landing/src/Flow/Website/Blog/Posts.php b/web/landing/src/Flow/Website/Blog/Posts.php index 4357cf4ee..b767a0a1a 100644 --- a/web/landing/src/Flow/Website/Blog/Posts.php +++ b/web/landing/src/Flow/Website/Blog/Posts.php @@ -20,36 +20,42 @@ final class Posts 'description' => 'Learn how to extract data from Google Analytics API using Flow PHP but also how to build a custom data extractor.', 'date' => '2024-04-04', 'slug' => 'building-custom-extractor-google-analytics', + 'author' => 'norberttech', ], [ 'title' => 'Scalar Functions', 'description' => 'Scalar functions are one of the most important building blocks of Flow. Learn how to build and use custom scalar functions in Flow PHP.', 'date' => '2024-08-08', 'slug' => 'scalar-functions', + 'author' => 'norberttech', ], [ 'title' => 'Data processing in PHP', 'description' => 'Processing datasets is a common problem in almost any software. Learn how to process datasets in PHP just like it\'s being done in other programming languages.', 'date' => '2025-01-25', 'slug' => 'data-processing-in-php', + 'author' => 'norberttech', ], [ 'title' => 'Flow PHP Release Cycle', 'description' => 'Explanation of the updated Flow PHP release cycle that improves adaptability of new features.', 'date' => '2025-03-16', 'slug' => 'flow-php-release-cycle', + 'author' => 'norberttech', ], [ 'title' => 'Flow PHP - Release 0.41.0', 'description' => 'Summary of things changed in Flow PHP 0.41.0 release', 'date' => '2026-06-29', 'slug' => 'flow-php-release-0410', + 'author' => 'norberttech', ], [ 'title' => 'Performance Optimizations', 'description' => 'Floe is a dedicated binary format, created to speedup serialization and reduce serialized content size of Rows.', 'date' => '2026-07-23', 'slug' => 'performance-optimizations', + 'author' => 'norberttech', ], ]; @@ -58,7 +64,7 @@ final class Posts */ public function all(): array { - return array_map(static fn(array $data): Post => Post::fromArray($data), array_reverse($this->posts)); + return array_map(Post::fromArray(...), array_reverse($this->posts)); } public function findByDateAndSlug(string $date, string $slug): Post diff --git a/web/landing/src/Flow/Website/Controller/BlogController.php b/web/landing/src/Flow/Website/Controller/BlogController.php index 8a9abfb81..299cfeeae 100644 --- a/web/landing/src/Flow/Website/Controller/BlogController.php +++ b/web/landing/src/Flow/Website/Controller/BlogController.php @@ -5,6 +5,7 @@ namespace Flow\Website\Controller; use Flow\Website\Blog\Posts; +use Flow\Website\SocialCard\Blog\CardGenerator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -12,13 +13,16 @@ final class BlogController extends AbstractController { #[Route('/blog/{date}/{slug}', name: 'blog_post')] - public function post(string $date, string $slug): Response + public function post(string $date, string $slug, CardGenerator $socialCards): Response { + $post = (new Posts())->findByDateAndSlug($date, $slug); + return $this->render('blog/posts/' . $date . '/' . $slug . '/post.html.twig', [ 'template_folder' => 'blog/posts/' . $date . '/' . $slug, - 'post' => (new Posts())->findByDateAndSlug($date, $slug), + 'post' => $post, 'date' => $date, 'slug' => $slug, + 'social_image' => $socialCards->generate($post), ]); } diff --git a/web/landing/src/Flow/Website/Controller/WorkShopController.php b/web/landing/src/Flow/Website/Controller/WorkShopController.php index 6c40fbb17..087f9b018 100644 --- a/web/landing/src/Flow/Website/Controller/WorkShopController.php +++ b/web/landing/src/Flow/Website/Controller/WorkShopController.php @@ -4,6 +4,8 @@ namespace Flow\Website\Controller; +use Flow\Website\SocialCard\WorkShop\CardGenerator; +use Flow\Website\WorkShop\Listings; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -23,16 +25,19 @@ public function __construct( #[Route('/work-shop', name: 'work_shop', options: ['sitemap' => false])] public function index(): Response { - return $this->render('work-shop/index.html.twig'); + return $this->render('work-shop/index.html.twig', [ + 'categories' => (new Listings())->all(), + ]); } #[Route('/work-shop/blueprints/symfony-backoffice', name: 'work_shop_blueprint_symfony', options: [ 'sitemap' => false, ])] - public function blueprintSymfony(): Response + public function blueprintSymfony(CardGenerator $socialCards): Response { return $this->render('work-shop/blueprints/symfony-backoffice/index.html.twig', [ 'listing_checkout_url' => $this->checkoutLinks['symfony_backoffice'] ?? null, + 'social_image' => $socialCards->generate((new Listings())->findBySlug('symfony-backoffice')), ]); } @@ -49,10 +54,11 @@ public function blueprintHowItWorks(): Response #[Route('/work-shop/ai/claude-skills', name: 'work_shop_ai_claude_skills', options: [ 'sitemap' => false, ])] - public function aiClaudeSkills(): Response + public function aiClaudeSkills(CardGenerator $socialCards): Response { return $this->render('work-shop/ai/claude-skills/index.html.twig', [ 'listing_checkout_url' => $this->checkoutLinks['claude_skills'] ?? null, + 'social_image' => $socialCards->generate((new Listings())->findBySlug('claude-skills')), ]); } @@ -67,30 +73,33 @@ public function aiHowItWorks(): Response #[Route('/work-shop/sponsoring/1-month', name: 'work_shop_sponsoring_1m', options: [ 'sitemap' => false, ])] - public function sponsoring1Month(): Response + public function sponsoring1Month(CardGenerator $socialCards): Response { return $this->render('work-shop/sponsoring/1-month/index.html.twig', [ 'listing_checkout_url' => $this->checkoutLinks['sponsoring_1m'] ?? null, + 'social_image' => $socialCards->generate((new Listings())->findBySlug('sponsoring-1-month')), ]); } #[Route('/work-shop/sponsoring/6-months', name: 'work_shop_sponsoring_6m', options: [ 'sitemap' => false, ])] - public function sponsoring6Months(): Response + public function sponsoring6Months(CardGenerator $socialCards): Response { return $this->render('work-shop/sponsoring/6-months/index.html.twig', [ 'listing_checkout_url' => $this->checkoutLinks['sponsoring_6m'] ?? null, + 'social_image' => $socialCards->generate((new Listings())->findBySlug('sponsoring-6-months')), ]); } #[Route('/work-shop/sponsoring/12-months', name: 'work_shop_sponsoring_12m', options: [ 'sitemap' => false, ])] - public function sponsoring12Months(): Response + public function sponsoring12Months(CardGenerator $socialCards): Response { return $this->render('work-shop/sponsoring/12-months/index.html.twig', [ 'listing_checkout_url' => $this->checkoutLinks['sponsoring_12m'] ?? null, + 'social_image' => $socialCards->generate((new Listings())->findBySlug('sponsoring-12-months')), ]); } @@ -101,9 +110,11 @@ public function success(): Response } #[Route('/work-shop/consulting', name: 'work_shop_consulting', options: ['sitemap' => false])] - public function consulting(): Response + public function consulting(CardGenerator $socialCards): Response { - return $this->render('work-shop/consulting/index.html.twig'); + return $this->render('work-shop/consulting/index.html.twig', [ + 'social_image' => $socialCards->generate((new Listings())->findBySlug('consulting')), + ]); } #[Route('/work-shop/terms-of-sales', name: 'work_shop_terms_of_sales', options: ['sitemap' => false])] diff --git a/web/landing/src/Flow/Website/SocialCard/AvatarCache.php b/web/landing/src/Flow/Website/SocialCard/AvatarCache.php new file mode 100644 index 000000000..1708d081e --- /dev/null +++ b/web/landing/src/Flow/Website/SocialCard/AvatarCache.php @@ -0,0 +1,57 @@ +cacheDir . '/' . $handle . '.png'; + + if (file_exists($path)) { + return $path; + } + + try { + $avatar = $this->httpClient->request('GET', sprintf($this->urlTemplate, $handle), [ + 'timeout' => 5, + ])->getContent(); + } catch (ExceptionInterface) { + return null; + } + + if (getimagesizefromstring($avatar) === false) { + return null; + } + + if ( + !is_dir($this->cacheDir) + && !mkdir($this->cacheDir, permissions: 0o755, recursive: true) + && !is_dir($this->cacheDir) + ) { + return null; + } + + file_put_contents($path, $avatar); + + return $path; + } +} diff --git a/web/landing/src/Flow/Website/SocialCard/Blog/CardGenerator.php b/web/landing/src/Flow/Website/SocialCard/Blog/CardGenerator.php new file mode 100644 index 000000000..268c8f25f --- /dev/null +++ b/web/landing/src/Flow/Website/SocialCard/Blog/CardGenerator.php @@ -0,0 +1,65 @@ +painter->canvas(); + + $this->painter->drawElephant($canvas); + $this->painter->drawEyebrow($canvas, 'BLOG'); + $this->painter->drawDescription($canvas, $post->description, $this->painter->drawTitle($canvas, $post->title)); + $this->painter->drawFooter( + $canvas, + '@' . $post->author, + $post->date->format('M j, Y'), + $this->avatars->fetch($post->author), + ); + $this->painter->drawAccentBar($canvas); + + return $this->save($canvas, $post); + } + + public function relativePath(Post $post): string + { + return sprintf('/images/blog/social/%s-%s.png', $post->date->format('Y-m-d'), $post->slug); + } + + public function save(GdImage $canvas, Post $post): string + { + $path = $this->publicDir . $this->relativePath($post); + $directory = dirname($path); + + if (!is_dir($directory) && !mkdir($directory, permissions: 0o755, recursive: true) && !is_dir($directory)) { + throw new RuntimeException('Failed to create directory: ' . $directory); + } + + if (imagepng($canvas, $path) === false) { + throw new RuntimeException('Failed to write social card: ' . $path); + } + + return $this->relativePath($post); + } +} diff --git a/web/landing/src/Flow/Website/SocialCard/CardPainter.php b/web/landing/src/Flow/Website/SocialCard/CardPainter.php new file mode 100644 index 000000000..02911d4b6 --- /dev/null +++ b/web/landing/src/Flow/Website/SocialCard/CardPainter.php @@ -0,0 +1,263 @@ +color($canvas, [ + (int) round( + self::BACKGROUND_TOP[0] + + (($y / self::HEIGHT) * (self::BACKGROUND_BOTTOM[0] - self::BACKGROUND_TOP[0])), + ), + (int) round( + self::BACKGROUND_TOP[1] + + (($y / self::HEIGHT) * (self::BACKGROUND_BOTTOM[1] - self::BACKGROUND_TOP[1])), + ), + (int) round( + self::BACKGROUND_TOP[2] + + (($y / self::HEIGHT) * (self::BACKGROUND_BOTTOM[2] - self::BACKGROUND_TOP[2])), + ), + ]); + imageline($canvas, x1: 0, y1: $y, x2: self::WIDTH - 1, y2: $y, color: $background); + } + + return $canvas; + } + + /** + * @param array{int, int, int} $rgb + */ + public function color(GdImage $image, array $rgb, int $alpha = 0): int + { + $color = imagecolorallocatealpha($image, $rgb[0], $rgb[1], $rgb[2], $alpha); + + if ($color === false) { + throw new RuntimeException('Failed to allocate color'); + } + + return $color; + } + + public function drawAccentBar(GdImage $canvas): void + { + for ($x = 0; $x < self::WIDTH; $x++) { + $gradient = $this->color($canvas, [ + (int) round(self::ORANGE[0] + (($x / self::WIDTH) * (self::BLUE[0] - self::ORANGE[0]))), + (int) round(self::ORANGE[1] + (($x / self::WIDTH) * (self::BLUE[1] - self::ORANGE[1]))), + (int) round(self::ORANGE[2] + (($x / self::WIDTH) * (self::BLUE[2] - self::ORANGE[2]))), + ]); + imageline($canvas, x1: $x, y1: self::HEIGHT - 8, x2: $x, y2: self::HEIGHT - 1, color: $gradient); + } + } + + public function drawDescription(GdImage $canvas, string $description, int $baseline): void + { + $font = $this->resourcesDir . '/fonts/Cabin-Regular.ttf'; + + foreach ((new TextWrapper($font))->wrap( + $description, + fontSize: 19, + maxWidth: self::CONTENT_WIDTH, + maxLines: 3, + ) as $line) { + imagettftext( + $canvas, + size: 19, + angle: 0, + x: self::MARGIN, + y: $baseline, + color: $this->color($canvas, self::MUTED), + font_filename: $font, + text: $line, + ); + $baseline += 38; + } + } + + public function drawElephant(GdImage $canvas): void + { + $elephant = imagecreatefrompng($this->resourcesDir . '/images/elephant.png'); + + if ($elephant === false) { + throw new RuntimeException('Failed to load elephant image'); + } + + $width = 470; + $height = (int) round(($width * imagesy($elephant)) / imagesx($elephant)); + imagecopyresampled( + $canvas, + $elephant, + dst_x: 770, + dst_y: intdiv(self::HEIGHT - $height, num2: 2), + src_x: 0, + src_y: 0, + dst_width: $width, + dst_height: $height, + src_width: imagesx($elephant), + src_height: imagesy($elephant), + ); + } + + public function drawEyebrow(GdImage $canvas, string $section): void + { + $fontBold = $this->resourcesDir . '/fonts/Cabin-Bold.ttf'; + $fontRegular = $this->resourcesDir . '/fonts/Cabin-Regular.ttf'; + + $eyebrow = 'FLOW PHP'; + imagettftext( + $canvas, + size: 18, + angle: 0, + x: self::MARGIN, + y: 116, + color: $this->color($canvas, self::ORANGE), + font_filename: $fontBold, + text: $eyebrow, + ); + + $eyebrowWidth = (new TextWrapper($fontBold))->width($eyebrow, fontSize: 18); + imagettftext( + $canvas, + size: 18, + angle: 0, + x: self::MARGIN + $eyebrowWidth + 14, + y: 116, + color: $this->color($canvas, self::MUTED), + font_filename: $fontRegular, + text: '· ' . $section, + ); + } + + /** + * @param array{int, int, int} $primaryColor + */ + public function drawFooter( + GdImage $canvas, + string $primary, + ?string $secondary = null, + ?string $avatarPath = null, + array $primaryColor = self::BLUE, + ): void { + $fontBold = $this->resourcesDir . '/fonts/Cabin-Bold.ttf'; + $fontRegular = $this->resourcesDir . '/fonts/Cabin-Regular.ttf'; + + $x = self::MARGIN; + + if ( + $avatarPath !== null + && (new CircleAvatar())->draw($canvas, $avatarPath, x: self::MARGIN, y: 523, size: 64) + ) { + $x += 64 + 18; + } + + imagettftext( + $canvas, + size: 17, + angle: 0, + x: $x, + y: 566, + color: $this->color($canvas, $primaryColor), + font_filename: $fontBold, + text: $primary, + ); + + if ($secondary === null) { + return; + } + + $primaryWidth = (new TextWrapper($fontBold))->width($primary, fontSize: 17); + imagettftext( + $canvas, + size: 17, + angle: 0, + x: $x + $primaryWidth + 14, + y: 566, + color: $this->color($canvas, self::MUTED), + font_filename: $fontRegular, + text: '· ' . $secondary, + ); + } + + public function drawTitle(GdImage $canvas, string $title): int + { + $font = $this->resourcesDir . '/fonts/Cabin-Bold.ttf'; + $wrapper = new TextWrapper($font); + $titleSize = 44.0; + + foreach ([44.0, 40.0, 36.0, 32.0] as $size) { + $titleSize = $size; + + if (count($wrapper->wrap($title, $size, self::CONTENT_WIDTH)) <= 3) { + break; + } + } + + $baseline = 210; + + foreach ($wrapper->wrap($title, $titleSize, self::CONTENT_WIDTH, maxLines: 3) as $line) { + imagettftext( + $canvas, + size: $titleSize, + angle: 0, + x: self::MARGIN, + y: $baseline, + color: $this->color($canvas, self::WHITE), + font_filename: $font, + text: $line, + ); + $baseline += (int) round($titleSize * 1.667); + } + + return $baseline + 20; + } +} diff --git a/web/landing/src/Flow/Website/SocialCard/CircleAvatar.php b/web/landing/src/Flow/Website/SocialCard/CircleAvatar.php new file mode 100644 index 000000000..df3998716 --- /dev/null +++ b/web/landing/src/Flow/Website/SocialCard/CircleAvatar.php @@ -0,0 +1,103 @@ +load($avatarPath); + + if ($avatar === null) { + return false; + } + + $circle = imagecreatetruecolor($size, $size); + + if ($circle === false) { + return false; + } + + imagecopyresampled( + $circle, + $avatar, + dst_x: 0, + dst_y: 0, + src_x: 0, + src_y: 0, + dst_width: $size, + dst_height: $size, + src_width: imagesx($avatar), + src_height: imagesy($avatar), + ); + + if (!$this->mask($circle, $size)) { + return false; + } + + imagecopy($canvas, $circle, dst_x: $x, dst_y: $y, src_x: 0, src_y: 0, src_width: $size, src_height: $size); + + return true; + } + + public function load(string $avatarPath): ?GdImage + { + if (!file_exists($avatarPath)) { + return null; + } + + $bytes = file_get_contents($avatarPath); + + if ($bytes === false || getimagesizefromstring($bytes) === false) { + return null; + } + + $avatar = imagecreatefromstring($bytes); + + return $avatar === false ? null : $avatar; + } + + public function mask(GdImage $circle, int $size): bool + { + imagealphablending($circle, enable: false); + imagesavealpha($circle, enable: true); + $transparent = imagecolorallocatealpha($circle, red: 0, green: 0, blue: 0, alpha: 127); + + if ($transparent === false) { + return false; + } + + $radius = $size / 2; + + for ($pixelX = 0; $pixelX < $size; $pixelX++) { + for ($pixelY = 0; $pixelY < $size; $pixelY++) { + $dx = $pixelX - $radius + 0.5; + $dy = $pixelY - $radius + 0.5; + + if ((($dx * $dx) + ($dy * $dy)) > ($radius * $radius)) { + imagesetpixel($circle, $pixelX, $pixelY, $transparent); + } + } + } + + return true; + } +} diff --git a/web/landing/src/Flow/Website/SocialCard/TextWrapper.php b/web/landing/src/Flow/Website/SocialCard/TextWrapper.php new file mode 100644 index 000000000..fe4a81247 --- /dev/null +++ b/web/landing/src/Flow/Website/SocialCard/TextWrapper.php @@ -0,0 +1,71 @@ + + */ + public function wrap(string $text, float $fontSize, int $maxWidth, ?int $maxLines = null): array + { + $lines = []; + $line = ''; + + foreach (explode(' ', trim($text)) as $word) { + $candidate = $line === '' ? $word : $line . ' ' . $word; + + if ($line === '' || $this->width($candidate, $fontSize) <= $maxWidth) { + $line = $candidate; + + continue; + } + + $lines[] = $line; + $line = $word; + } + + $lines[] = $line; + + if ($maxLines === null || count($lines) <= $maxLines) { + return $lines; + } + + $kept = array_slice($lines, offset: 0, length: $maxLines - 1); + $last = $lines[$maxLines - 1]; + + while ($last !== '' && $this->width($last . '…', $fontSize) > $maxWidth) { + $last = trim(mb_substr($last, start: 0, length: -1)); + } + + $kept[] = $last . '…'; + + return $kept; + } + + public function width(string $text, float $fontSize): int + { + $box = imagettfbbox($fontSize, angle: 0, font_filename: $this->fontPath, string: $text); + + if ($box === false) { + throw new RuntimeException('Failed to measure text: ' . $text); + } + + return (int) $box[2] - (int) $box[0]; + } +} diff --git a/web/landing/src/Flow/Website/SocialCard/WorkShop/CardGenerator.php b/web/landing/src/Flow/Website/SocialCard/WorkShop/CardGenerator.php new file mode 100644 index 000000000..2692785b2 --- /dev/null +++ b/web/landing/src/Flow/Website/SocialCard/WorkShop/CardGenerator.php @@ -0,0 +1,64 @@ +painter->canvas(); + + $this->painter->drawElephant($canvas); + $this->painter->drawEyebrow($canvas, 'WORK-SHOP'); + $this->painter->drawDescription($canvas, $listing->blurb, $this->painter->drawTitle($canvas, $listing->name)); + + if ($listing->price !== null) { + $this->painter->drawFooter($canvas, $listing->price, $listing->category, primaryColor: CardPainter::ORANGE); + } else { + $this->painter->drawFooter($canvas, $listing->category, primaryColor: CardPainter::ORANGE); + } + + $this->painter->drawAccentBar($canvas); + + return $this->save($canvas, $listing); + } + + public function relativePath(Listing $listing): string + { + return sprintf('/images/work-shop/social/%s.png', $listing->slug); + } + + public function save(GdImage $canvas, Listing $listing): string + { + $path = $this->publicDir . $this->relativePath($listing); + $directory = dirname($path); + + if (!is_dir($directory) && !mkdir($directory, permissions: 0o755, recursive: true) && !is_dir($directory)) { + throw new RuntimeException('Failed to create directory: ' . $directory); + } + + if (imagepng($canvas, $path) === false) { + throw new RuntimeException('Failed to write social card: ' . $path); + } + + return $this->relativePath($listing); + } +} diff --git a/web/landing/src/Flow/Website/WorkShop/Category.php b/web/landing/src/Flow/Website/WorkShop/Category.php new file mode 100644 index 000000000..a51819d44 --- /dev/null +++ b/web/landing/src/Flow/Website/WorkShop/Category.php @@ -0,0 +1,58 @@ + + * } + */ +final readonly class Category +{ + /** + * @param list $listings + */ + public function __construct( + public string $id, + public string $heading, + public string $subtitle, + public array $listings, + ) {} + + public static function fromArray(array $data): self + { + $category = self::schema()->assert($data); + + return new self($category['id'], $category['heading'], $category['subtitle'], array_map( + static fn(array $listing): Listing => Listing::fromArray($listing, $category['heading']), + $category['listings'], + )); + } + + /** + * @return Type + */ + public static function schema(): Type + { + return type_structure([ + 'id' => type_string(), + 'heading' => type_string(), + 'subtitle' => type_string(), + 'listings' => type_list(Listing::schema()), + ]); + } +} diff --git a/web/landing/src/Flow/Website/WorkShop/Listing.php b/web/landing/src/Flow/Website/WorkShop/Listing.php new file mode 100644 index 000000000..3807d9e43 --- /dev/null +++ b/web/landing/src/Flow/Website/WorkShop/Listing.php @@ -0,0 +1,89 @@ +, + * available: bool, + * price: null|string, + * price_note: null|string, + * badge: null|string, + * highlight: bool, + * route: null|string, + * image: null|string + * } + */ +final readonly class Listing +{ + /** + * @param list $features + */ + public function __construct( + public string $name, + public string $slug, + public string $category, + public string $blurb, + public array $features, + public bool $available, + public ?string $price = null, + public ?string $priceNote = null, + public ?string $badge = null, + public bool $highlight = false, + public ?string $route = null, + public ?string $image = null, + ) {} + + public static function fromArray(array $data, string $category): self + { + $listing = self::schema()->assert($data); + + return new self( + $listing['name'], + $listing['slug'], + $category, + $listing['blurb'], + $listing['features'], + $listing['available'], + $listing['price'], + $listing['price_note'], + $listing['badge'], + $listing['highlight'], + $listing['route'], + $listing['image'], + ); + } + + /** + * @return Type + */ + public static function schema(): Type + { + return type_structure([ + 'name' => type_string(), + 'slug' => type_string(), + 'blurb' => type_string(), + 'features' => type_list(type_string()), + 'available' => type_boolean(), + 'price' => type_optional(type_string()), + 'price_note' => type_optional(type_string()), + 'badge' => type_optional(type_string()), + 'highlight' => type_boolean(), + 'route' => type_optional(type_string()), + 'image' => type_optional(type_string()), + ]); + } +} diff --git a/web/landing/src/Flow/Website/WorkShop/Listings.php b/web/landing/src/Flow/Website/WorkShop/Listings.php new file mode 100644 index 000000000..d787704e6 --- /dev/null +++ b/web/landing/src/Flow/Website/WorkShop/Listings.php @@ -0,0 +1,186 @@ + */ + private array $categories = [ + [ + 'id' => 'blueprints', + 'heading' => 'Blueprints', + 'subtitle' => 'Working applications you can clone, run and pull apart. Read how the pieces fit together, then build your own on top.', + 'listings' => [ + [ + 'name' => 'Symfony Backoffice Blueprint', + 'slug' => 'symfony-backoffice', + 'blurb' => 'A working Symfony back office built on Flow PHP. One schema per model drives the database, the API and the imports, and every request is traced from the browser down to the SQL query.', + 'features' => [ + 'Schema drives the database, API and imports', + 'Streaming CSV, JSON and XML exports', + 'OpenTelemetry across browser and backend', + ], + 'available' => true, + 'price' => '$59', + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => 'work_shop_blueprint_symfony', + 'image' => 'images/work-shop/symfony-backoffice-app.png', + ], + [ + 'name' => 'More Blueprints', + 'slug' => 'more-blueprints', + 'blurb' => 'The Symfony back office is the first one. More are being built, same standards, different frameworks and different problems.', + 'features' => [ + 'Fully tested and statically analysed', + 'Something you want to see? Let us know', + ], + 'available' => false, + 'price' => null, + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => null, + 'image' => null, + ], + ], + ], + [ + 'id' => 'ai', + 'heading' => 'AI', + 'subtitle' => 'Claude Code plugins that make your agent work the way this codebase is built: planned tasks, gated reviews, and PHP quality rules that load themselves.', + 'listings' => [ + [ + 'name' => 'Claude Code Skills', + 'slug' => 'claude-skills', + 'blurb' => 'Two Claude Code plugins from one private marketplace: a task-driven engineering workflow with eleven gated skills, and PHP code-quality skills that auto-load whenever Claude touches PHP.', + 'features' => [ + 'Plan, implement, clean up and review through gated skills', + 'Findings survive adversarial verification before you see them', + 'Type narrowing and testing rules that load themselves', + ], + 'available' => true, + 'price' => '$10', + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => 'work_shop_ai_claude_skills', + 'image' => 'images/work-shop/claude-skills.svg', + ], + ], + ], + [ + 'id' => 'sponsoring', + 'heading' => 'Sponsoring', + 'subtitle' => 'Flow PHP is MIT and free. Sponsoring funds the work that keeps it that way, and gets you into the sponsor-only channels on our Discord.', + 'listings' => [ + [ + 'name' => 'Sponsoring — 1 Month', + 'slug' => 'sponsoring-1-month', + 'blurb' => 'A small monthly contribution towards maintenance, releases and infrastructure. In exchange: the Sponsor role on our Discord — a custom username color and the exclusive channels where the roadmap happens.', + 'features' => [ + 'Sponsor role with a custom username color', + 'Access to exclusive sponsor-only channels', + 'Renews monthly, cancel anytime', + ], + 'available' => true, + 'price' => '$5', + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => 'work_shop_sponsoring_1m', + 'image' => 'images/work-shop/sponsoring-1m.svg', + ], + [ + 'name' => 'Sponsoring — 6 Months', + 'slug' => 'sponsoring-6-months', + 'blurb' => 'The same sponsorship in one payment every six months, 3% off the monthly price. The Sponsor role, a custom username color and the exclusive channels included.', + 'features' => [ + 'Everything from the monthly sponsorship', + 'Save 3% vs paying monthly', + 'Renews every 6 months, cancel anytime', + ], + 'available' => true, + 'price' => '$29', + 'price_note' => 'save 3%', + 'badge' => 'Best value', + 'highlight' => true, + 'route' => 'work_shop_sponsoring_6m', + 'image' => 'images/work-shop/sponsoring-6m.svg', + ], + [ + 'name' => 'Sponsoring — 12 Months', + 'slug' => 'sponsoring-12-months', + 'blurb' => 'A full year of sponsoring in one payment, 8% off the monthly price. The Sponsor role, a custom username color and the exclusive channels included.', + 'features' => [ + 'Everything from the monthly sponsorship', + 'Save 8% vs paying monthly', + 'Renews every 12 months, cancel anytime', + ], + 'available' => true, + 'price' => '$55', + 'price_note' => 'save 8%', + 'badge' => null, + 'highlight' => false, + 'route' => 'work_shop_sponsoring_12m', + 'image' => 'images/work-shop/sponsoring-12m.svg', + ], + ], + ], + [ + 'id' => 'consulting', + 'heading' => 'Consulting', + 'subtitle' => 'Data engineering, architecture and observability work, from a two day workshop to a fractional architect on your team.', + 'listings' => [ + [ + 'name' => 'Consulting', + 'slug' => 'consulting', + 'blurb' => 'Sixteen years of data engineering, software architecture and observability work, from pipelines that hold up to APM bills that come down.', + 'features' => [ + 'Data engineering, architecture, observability', + 'Workshops, fractional architect, or hands-on build', + 'Quoted per engagement, first call free', + ], + 'available' => true, + 'price' => null, + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => 'work_shop_consulting', + 'image' => 'images/work-shop/consulting.jpg', + ], + ], + ], + ]; + + /** + * @return list + */ + public function all(): array + { + return array_map(Category::fromArray(...), $this->categories); + } + + public function findBySlug(string $slug): Listing + { + foreach ($this->all() as $category) { + foreach ($category->listings as $listing) { + if ($listing->slug === $slug) { + return $listing; + } + } + } + + throw new InvalidArgumentException('Listing not found: ' . $slug); + } +} diff --git a/web/landing/templates/base.html.twig b/web/landing/templates/base.html.twig index a42ca6aca..dd4f03b3e 100644 --- a/web/landing/templates/base.html.twig +++ b/web/landing/templates/base.html.twig @@ -28,15 +28,17 @@ - + - - - + {% block social_image %} + + + + + {% endblock %} - diff --git a/web/landing/templates/blog/post.html.twig b/web/landing/templates/blog/post.html.twig index f6678149c..2dccd658f 100644 --- a/web/landing/templates/blog/post.html.twig +++ b/web/landing/templates/blog/post.html.twig @@ -8,6 +8,17 @@ {{ post.description }} {%- endblock -%} +{%- block og_type -%}article{%- endblock -%} + +{% block social_image %} + + + + + + +{% endblock %} + {%- block hero -%}{%- endblock -%} {% block main %} @@ -27,6 +38,18 @@ RSS +
+ + {{ post.author }} + +
+ @{{ post.author }} + +
+
{{ block('article') }} diff --git a/web/landing/templates/blog/posts.html.twig b/web/landing/templates/blog/posts.html.twig index 6f42e2f97..e40b0fe24 100644 --- a/web/landing/templates/blog/posts.html.twig +++ b/web/landing/templates/blog/posts.html.twig @@ -32,9 +32,14 @@ {% for post in posts %} {% set postUrl = path('blog_post', {date: post.date | date('Y-m-d'), slug: post.slug}) %}
- +
+ + + {{ post.author }} + +

{{ post.title }} diff --git a/web/landing/templates/work-shop/_listing.html.twig b/web/landing/templates/work-shop/_listing.html.twig index 88fe8aa51..a09516fd3 100644 --- a/web/landing/templates/work-shop/_listing.html.twig +++ b/web/landing/templates/work-shop/_listing.html.twig @@ -21,6 +21,19 @@ {%- block description -%}{{ listing_tagline|default('The Flow PHP Work-Shop. Blueprints and consulting for teams building with Flow.') }}{%- endblock -%} +{% block social_image %} + {% if social_image is defined %} + + + + + + + {% else %} + {{ parent() }} + {% endif %} +{% endblock %} + {%- block hero -%}{%- endblock -%} {# The Work-Shop is a commercial offer by the maintainer, not a community effort. #} diff --git a/web/landing/templates/work-shop/index.html.twig b/web/landing/templates/work-shop/index.html.twig index ffd50cd5f..5aa0de846 100644 --- a/web/landing/templates/work-shop/index.html.twig +++ b/web/landing/templates/work-shop/index.html.twig @@ -14,100 +14,6 @@ {%- block contributors -%}{%- endblock -%} {% block main %} - {% set categories = [ - { - id: 'blueprints', - heading: 'Blueprints', - subtitle: 'Working applications you can clone, run and pull apart. Read how the pieces fit together, then build your own on top.', - listings: [ - { - name: 'Symfony Backoffice Blueprint', - price: '$59', - href: path('work_shop_blueprint_symfony'), - image: asset('images/work-shop/symfony-backoffice-app.png'), - blurb: 'A working Symfony back office built on Flow PHP. One schema per model drives the database, the API and the imports, and every request is traced from the browser down to the SQL query.', - features: ['Schema drives the database, API and imports', 'Streaming CSV, JSON and XML exports', 'OpenTelemetry across browser and backend'], - available: true - }, - { - name: 'More Blueprints', - blurb: 'The Symfony back office is the first one. More are being built, same standards, different frameworks and different problems.', - features: ['Fully tested and statically analysed', 'Something you want to see? Let us know'], - available: false - } - ] - }, - { - id: 'ai', - heading: 'AI', - subtitle: 'Claude Code plugins that make your agent work the way this codebase is built: planned tasks, gated reviews, and PHP quality rules that load themselves.', - listings: [ - { - name: 'Claude Code Skills', - price: '$10', - href: path('work_shop_ai_claude_skills'), - image: asset('images/work-shop/claude-skills.svg'), - blurb: 'Two Claude Code plugins from one private marketplace: a task-driven engineering workflow with eleven gated skills, and PHP code-quality skills that auto-load whenever Claude touches PHP.', - features: ['Plan, implement, clean up and review through gated skills', 'Findings survive adversarial verification before you see them', 'Type narrowing and testing rules that load themselves'], - available: true - } - ] - }, - { - id: 'sponsoring', - heading: 'Sponsoring', - subtitle: 'Flow PHP is MIT and free. Sponsoring funds the work that keeps it that way, and gets you into the sponsor-only channels on our Discord.', - listings: [ - { - name: 'Sponsoring — 1 Month', - price: '$5', - href: path('work_shop_sponsoring_1m'), - image: asset('images/work-shop/sponsoring-1m.svg'), - blurb: 'A small monthly contribution towards maintenance, releases and infrastructure. In exchange: the Sponsor role on our Discord — a custom username color and the exclusive channels where the roadmap happens.', - features: ['Sponsor role with a custom username color', 'Access to exclusive sponsor-only channels', 'Renews monthly, cancel anytime'], - available: true - }, - { - name: 'Sponsoring — 6 Months', - price: '$29', - price_note: 'save 3%', - badge: 'Best value', - highlight: true, - href: path('work_shop_sponsoring_6m'), - image: asset('images/work-shop/sponsoring-6m.svg'), - blurb: 'The same sponsorship in one payment every six months, 3% off the monthly price. The Sponsor role, a custom username color and the exclusive channels included.', - features: ['Everything from the monthly sponsorship', 'Save 3% vs paying monthly', 'Renews every 6 months, cancel anytime'], - available: true - }, - { - name: 'Sponsoring — 12 Months', - price: '$55', - price_note: 'save 8%', - href: path('work_shop_sponsoring_12m'), - image: asset('images/work-shop/sponsoring-12m.svg'), - blurb: 'A full year of sponsoring in one payment, 8% off the monthly price. The Sponsor role, a custom username color and the exclusive channels included.', - features: ['Everything from the monthly sponsorship', 'Save 8% vs paying monthly', 'Renews every 12 months, cancel anytime'], - available: true - } - ] - }, - { - id: 'consulting', - heading: 'Consulting', - subtitle: 'Data engineering, architecture and observability work, from a two day workshop to a fractional architect on your team.', - listings: [ - { - name: 'Consulting', - href: path('work_shop_consulting'), - image: asset('images/work-shop/consulting.jpg'), - blurb: 'Sixteen years of data engineering, software architecture and observability work, from pipelines that hold up to APM bills that come down.', - features: ['Data engineering, architecture, observability', 'Workshops, fractional architect, or hands-on build', 'Quoted per engagement, first call free'], - available: true - } - ] - } - ] %} -
@@ -154,17 +60,17 @@
{% for listing in category.listings %} - {# Listings without a href are placeholders, so they render as plain cards. #} - {% set tag = listing.href is defined ? 'a' : 'div' %} - <{{ tag }} {% if listing.href is defined %}href="{{ listing.href }}"{% endif %} + {# Listings without a route are placeholders, so they render as plain cards. #} + {% set tag = listing.route ? 'a' : 'div' %} + <{{ tag }} {% if listing.route %}href="{{ path(listing.route) }}"{% endif %} class="card group flex flex-col no-underline hover:no-underline - {{ listing.href is defined + {{ listing.route ? 'card-hover transition duration-200 hover:-translate-y-1 hover:shadow-xl hover:shadow-black/5 dark:hover:shadow-black/40' : 'border-dashed bg-transparent opacity-60 saturate-0 dark:bg-transparent' }} - {{ listing.highlight|default(false) ? 'ring-2 ring-emerald-600/40 dark:ring-emerald-400/40' }}"> + {{ listing.highlight ? 'ring-2 ring-emerald-600/40 dark:ring-emerald-400/40' }}">
- {% if listing.image is defined %} - {{ listing.name }} {% else %}
@@ -174,7 +80,7 @@

{{ listing.name }}

- {% if listing.badge is defined %} + {% if listing.badge %} {{ listing.badge }} @@ -197,18 +103,18 @@
- {% if listing.price is defined %} + {% if listing.price %} {{ listing.price }} excl. tax - {% if listing.price_note is defined %} + {% if listing.priceNote %} - {{ listing.price_note }} + {{ listing.priceNote }} {% endif %} {% endif %} - {% if listing.href is defined %} + {% if listing.route %} View details diff --git a/web/landing/tests/Flow/Website/Tests/Functional/BlogControllerTest.php b/web/landing/tests/Flow/Website/Tests/Functional/BlogControllerTest.php new file mode 100644 index 000000000..8f2a9f216 --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Functional/BlogControllerTest.php @@ -0,0 +1,55 @@ +request('GET', '/blog'); + + self::assertResponseIsSuccessful(); + static::assertStringContainsString( + (new Posts())->all()[0]->title, + $crawler->filter('article h2')->first()->text(), + ); + } + + public function test_blog_post_page_generates_social_card_and_points_open_graph_image_to_it(): void + { + $post = (new Posts())->all()[0]; + $socialCardPath = sprintf('/images/blog/social/%s-%s.png', $post->date->format('Y-m-d'), $post->slug); + + $client = self::createClient(); + $crawler = $client->request('GET', sprintf('/blog/%s/%s', $post->date->format('Y-m-d'), $post->slug)); + + self::assertResponseIsSuccessful(); + static::assertStringEndsWith( + $socialCardPath, + (string) $crawler->filter('meta[property="og:image"]')->attr('content'), + ); + static::assertStringEndsWith( + $socialCardPath, + (string) $crawler->filter('meta[name="twitter:image"]')->attr('content'), + ); + static::assertSame('article', $crawler->filter('meta[property="og:type"]')->attr('content')); + static::assertFileExists(dirname(__DIR__, levels: 5) . '/public' . $socialCardPath); + } + + #[Override] + protected static function getKernelClass(): string + { + return Kernel::class; + } +} diff --git a/web/landing/tests/Flow/Website/Tests/Functional/WorkShopControllerTest.php b/web/landing/tests/Flow/Website/Tests/Functional/WorkShopControllerTest.php index 1d9199dba..68cee91d3 100644 --- a/web/landing/tests/Flow/Website/Tests/Functional/WorkShopControllerTest.php +++ b/web/landing/tests/Flow/Website/Tests/Functional/WorkShopControllerTest.php @@ -9,6 +9,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\Routing\RouterInterface; +use function dirname; use function Flow\Types\DSL\type_instance_of; final class WorkShopControllerTest extends WebTestCase @@ -384,6 +385,54 @@ public function test_symfony_backoffice_has_carousel_and_covered_features(): voi static::assertGreaterThan(1, $crawler->filter('[data-work-shop-carousel-target="slide"]')->count()); } + public function test_listing_pages_generate_social_cards_and_point_open_graph_image_to_them(): void + { + $client = self::createClient(); + + $pages = [ + '/work-shop/blueprints/symfony-backoffice' => '/images/work-shop/social/symfony-backoffice.png', + '/work-shop/ai/claude-skills' => '/images/work-shop/social/claude-skills.png', + '/work-shop/sponsoring/1-month' => '/images/work-shop/social/sponsoring-1-month.png', + '/work-shop/sponsoring/6-months' => '/images/work-shop/social/sponsoring-6-months.png', + '/work-shop/sponsoring/12-months' => '/images/work-shop/social/sponsoring-12-months.png', + '/work-shop/consulting' => '/images/work-shop/social/consulting.png', + ]; + + foreach ($pages as $path => $socialCardPath) { + $crawler = $client->request('GET', $path); + + self::assertResponseIsSuccessful(); + static::assertStringEndsWith( + $socialCardPath, + (string) $crawler->filter('meta[property="og:image"]')->attr('content'), + $path, + ); + static::assertStringEndsWith( + $socialCardPath, + (string) $crawler->filter('meta[name="twitter:image"]')->attr('content'), + $path, + ); + static::assertFileExists(dirname(__DIR__, levels: 5) . '/public' . $socialCardPath, $path); + } + } + + public function test_non_listing_work_shop_pages_keep_the_default_social_image(): void + { + $client = self::createClient(); + + foreach (['/work-shop', '/work-shop/blueprints/how-it-works', '/work-shop/ai/how-it-works'] as $path) { + $crawler = $client->request('GET', $path); + + self::assertResponseIsSuccessful(); + // The default banner is served through the asset mapper, so its filename carries a content hash. + static::assertMatchesRegularExpression( + '#/assets/images/banner-[0-9a-f]+\.png$#', + (string) $crawler->filter('meta[property="og:image"]')->attr('content'), + $path, + ); + } + } + #[Override] protected static function getKernelClass(): string { diff --git a/web/landing/tests/Flow/Website/Tests/Mother/ListingMother.php b/web/landing/tests/Flow/Website/Tests/Mother/ListingMother.php new file mode 100644 index 000000000..f110d50f3 --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Mother/ListingMother.php @@ -0,0 +1,61 @@ + 'More Blueprints', + 'slug' => 'more-blueprints', + 'blurb' => 'More are being built, same standards, different frameworks and different problems.', + 'features' => ['Fully tested and statically analysed'], + 'available' => false, + 'price' => null, + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => null, + 'image' => null, + ], 'Blueprints'); + } + + public static function priced(): Listing + { + return Listing::fromArray([ + 'name' => 'Symfony Backoffice Blueprint', + 'slug' => 'symfony-backoffice', + 'blurb' => 'A working Symfony back office built on Flow PHP. One schema per model drives the database, the API and the imports.', + 'features' => ['Schema drives the database, API and imports'], + 'available' => true, + 'price' => '$59', + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => 'work_shop_blueprint_symfony', + 'image' => 'images/work-shop/symfony-backoffice-app.png', + ], 'Blueprints'); + } + + public static function unpriced(): Listing + { + return Listing::fromArray([ + 'name' => 'Consulting', + 'slug' => 'consulting', + 'blurb' => 'Sixteen years of data engineering, software architecture and observability work.', + 'features' => ['Data engineering, architecture, observability'], + 'available' => true, + 'price' => null, + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => 'work_shop_consulting', + 'image' => 'images/work-shop/consulting.jpg', + ], 'Consulting'); + } +} diff --git a/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/AvatarCacheTest.php b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/AvatarCacheTest.php new file mode 100644 index 000000000..614bbdcd2 --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/AvatarCacheTest.php @@ -0,0 +1,68 @@ +fetch('norberttech')); + static::assertStringEqualsFile($cacheDir . '/norberttech.png', $avatar); + } + + public function test_returns_already_cached_avatar_without_fetching(): void + { + $cacheDir = sys_get_temp_dir() . '/' . uniqid('avatar-cache-', more_entropy: true); + mkdir($cacheDir, permissions: 0o755, recursive: true); + file_put_contents($cacheDir . '/norberttech.png', data: 'cached-bytes'); + + $cache = new AvatarCache(new MockHttpClient([]), $cacheDir); + + static::assertSame($cacheDir . '/norberttech.png', $cache->fetch('norberttech')); + static::assertStringEqualsFile($cacheDir . '/norberttech.png', 'cached-bytes'); + } + + public function test_returns_null_when_fetch_fails(): void + { + $cacheDir = sys_get_temp_dir() . '/' . uniqid('avatar-cache-', more_entropy: true); + + $cache = new AvatarCache(new MockHttpClient(new MockResponse('', ['http_code' => 404])), $cacheDir); + + static::assertNull($cache->fetch('norberttech')); + static::assertFalse(file_exists($cacheDir . '/norberttech.png')); + } + + public function test_returns_null_when_response_is_not_an_image(): void + { + $cacheDir = sys_get_temp_dir() . '/' . uniqid('avatar-cache-', more_entropy: true); + + $cache = new AvatarCache(new MockHttpClient(new MockResponse('Not Found')), $cacheDir); + + static::assertNull($cache->fetch('norberttech')); + static::assertFalse(file_exists($cacheDir . '/norberttech.png')); + } +} diff --git a/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/Blog/CardGeneratorTest.php b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/Blog/CardGeneratorTest.php new file mode 100644 index 000000000..7b0601a6a --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/Blog/CardGeneratorTest.php @@ -0,0 +1,78 @@ + 404])), $publicDir . '/avatars'), + $publicDir, + ); + $post = Post::fromArray([ + 'title' => 'Consuming APIs Without SDKs', + 'description' => 'How to consume any HTTP API with the Flow PHP DataFrame API, without vendor SDKs.', + 'date' => '2026-08-14', + 'slug' => 'consuming-apis-without-sdks', + 'author' => 'norberttech', + ]); + + $path = $generator->generate($post); + + static::assertSame('/images/blog/social/2026-08-14-consuming-apis-without-sdks.png', $path); + static::assertFileExists($publicDir . $path); + static::assertSame( + [1200, 630, IMAGETYPE_PNG], + array_slice((array) getimagesize($publicDir . $path), offset: 0, length: 3), + ); + + unlink($publicDir . $path); + } + + public function test_generates_a_card_for_a_long_title_and_description(): void + { + $publicDir = sys_get_temp_dir() . '/' . uniqid('social-card-', more_entropy: true); + $generator = new CardGenerator( + new CardPainter(dirname(__DIR__, levels: 7) . '/resources'), + new AvatarCache(new MockHttpClient(new MockResponse('', ['http_code' => 404])), $publicDir . '/avatars'), + $publicDir, + ); + $post = Post::fromArray([ + 'title' => 'A Very Long Blog Post Title That Definitely Does Not Fit Into A Single Line Of The Social Card And Must Be Wrapped And Ellipsized', + 'description' => 'An equally long description that goes on and on about data processing, extractors, transformers, loaders, schema inference, type systems and everything else that makes Flow PHP a unified data processing framework.', + 'date' => '2026-08-14', + 'slug' => 'long-title', + 'author' => 'norberttech', + ]); + + $path = $generator->generate($post); + + static::assertFileExists($publicDir . $path); + static::assertSame( + [1200, 630, IMAGETYPE_PNG], + array_slice((array) getimagesize($publicDir . $path), offset: 0, length: 3), + ); + + unlink($publicDir . $path); + } +} diff --git a/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/CardPainterTest.php b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/CardPainterTest.php new file mode 100644 index 000000000..37690b624 --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/CardPainterTest.php @@ -0,0 +1,53 @@ +canvas(); + + static::assertSame(CardPainter::WIDTH, imagesx($canvas)); + static::assertSame(CardPainter::HEIGHT, imagesy($canvas)); + static::assertSame(0x26_2238, imagecolorat($canvas, x: 10, y: 0)); + static::assertNotSame( + imagecolorat($canvas, x: 10, y: 0), + imagecolorat($canvas, x: 10, y: CardPainter::HEIGHT - 1), + ); + } + + public function test_draw_title_moves_the_baseline_further_down_for_longer_titles(): void + { + $painter = new CardPainter(dirname(__DIR__, levels: 6) . '/resources'); + + $shortTitleBaseline = $painter->drawTitle($painter->canvas(), 'Short'); + $longTitleBaseline = $painter->drawTitle( + $painter->canvas(), + 'A Considerably Longer Blog Post Title That Wraps Into Multiple Lines', + ); + + static::assertGreaterThan($shortTitleBaseline, $longTitleBaseline); + } + + public function test_drawing_changes_canvas_pixels(): void + { + $painter = new CardPainter(dirname(__DIR__, levels: 6) . '/resources'); + $canvas = $painter->canvas(); + + $before = imagecolorat($canvas, x: 0, y: CardPainter::HEIGHT - 2); + $painter->drawAccentBar($canvas); + + static::assertNotSame($before, imagecolorat($canvas, x: 0, y: CardPainter::HEIGHT - 2)); + } +} diff --git a/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/CircleAvatarTest.php b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/CircleAvatarTest.php new file mode 100644 index 000000000..f6c7dfebd --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/CircleAvatarTest.php @@ -0,0 +1,58 @@ +draw($canvas, $workDir . '/avatar.png', x: 10, y: 10, size: 48)); + static::assertSame(0xFF_0000, imagecolorat($canvas, x: 34, y: 34)); + } + + public function test_returns_false_for_missing_avatar_file(): void + { + $canvas = imagecreatetruecolor(width: 100, height: 100); + static::assertNotFalse($canvas); + + static::assertFalse((new CircleAvatar())->draw($canvas, '/nonexistent/avatar.png', x: 0, y: 0, size: 48)); + } + + public function test_returns_false_for_a_file_that_is_not_an_image(): void + { + $workDir = sys_get_temp_dir() . '/' . uniqid('circle-avatar-', more_entropy: true); + mkdir($workDir, permissions: 0o755, recursive: true); + file_put_contents($workDir . '/avatar.png', data: 'not-an-image'); + + $canvas = imagecreatetruecolor(width: 100, height: 100); + static::assertNotFalse($canvas); + + static::assertFalse((new CircleAvatar())->draw($canvas, $workDir . '/avatar.png', x: 0, y: 0, size: 48)); + } +} diff --git a/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/TextWrapperTest.php b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/TextWrapperTest.php new file mode 100644 index 000000000..becc4c939 --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/TextWrapperTest.php @@ -0,0 +1,71 @@ +wrap( + 'one two three four five six seven eight nine ten eleven twelve', + fontSize: 20, + maxWidth: 200, + maxLines: 2, + ); + + static::assertCount(2, $lines); + static::assertTrue(str_ends_with($lines[1], '…')); + static::assertLessThanOrEqual(200, $wrapper->width($lines[1], fontSize: 20)); + } + + public function test_keeps_short_text_in_a_single_line(): void + { + $wrapper = new TextWrapper(dirname(__DIR__, levels: 6) . '/resources/fonts/Cabin-Regular.ttf'); + + static::assertSame(['Flow PHP'], $wrapper->wrap('Flow PHP', fontSize: 20, maxWidth: 1000)); + } + + public function test_keeps_word_longer_than_max_width_on_its_own_line(): void + { + $wrapper = new TextWrapper(dirname(__DIR__, levels: 6) . '/resources/fonts/Cabin-Regular.ttf'); + + static::assertSame( + ['incomprehensibilities', 'yes'], + $wrapper->wrap('incomprehensibilities yes', fontSize: 20, maxWidth: 50), + ); + } + + public function test_width_grows_with_longer_text(): void + { + $wrapper = new TextWrapper(dirname(__DIR__, levels: 6) . '/resources/fonts/Cabin-Regular.ttf'); + + static::assertGreaterThan( + $wrapper->width('Flow', fontSize: 20), + $wrapper->width('Flow PHP Framework', fontSize: 20), + ); + } + + public function test_wraps_text_into_multiple_lines_fitting_max_width(): void + { + $wrapper = new TextWrapper(dirname(__DIR__, levels: 6) . '/resources/fonts/Cabin-Regular.ttf'); + + $lines = $wrapper->wrap('Consuming APIs Without SDKs in pure PHP with Flow', fontSize: 20, maxWidth: 200); + + static::assertGreaterThan(1, count($lines)); + + foreach ($lines as $line) { + static::assertLessThanOrEqual(200, $wrapper->width($line, fontSize: 20)); + } + } +} diff --git a/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/WorkShop/CardGeneratorTest.php b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/WorkShop/CardGeneratorTest.php new file mode 100644 index 000000000..808ca7c90 --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Unit/SocialCard/WorkShop/CardGeneratorTest.php @@ -0,0 +1,54 @@ +generate(ListingMother::priced()); + + static::assertSame('/images/work-shop/social/symfony-backoffice.png', $path); + static::assertFileExists($publicDir . $path); + static::assertSame( + [1200, 630, IMAGETYPE_PNG], + array_slice((array) getimagesize($publicDir . $path), offset: 0, length: 3), + ); + + unlink($publicDir . $path); + } + + public function test_generates_a_card_for_a_listing_without_a_price(): void + { + $publicDir = sys_get_temp_dir() . '/' . uniqid('social-card-', more_entropy: true); + $generator = new CardGenerator(new CardPainter(dirname(__DIR__, levels: 7) . '/resources'), $publicDir); + + $path = $generator->generate(ListingMother::unpriced()); + + static::assertSame('/images/work-shop/social/consulting.png', $path); + static::assertFileExists($publicDir . $path); + static::assertSame( + [1200, 630, IMAGETYPE_PNG], + array_slice((array) getimagesize($publicDir . $path), offset: 0, length: 3), + ); + + unlink($publicDir . $path); + } +} diff --git a/web/landing/tests/Flow/Website/Tests/Unit/WorkShop/CategoryTest.php b/web/landing/tests/Flow/Website/Tests/Unit/WorkShop/CategoryTest.php new file mode 100644 index 000000000..42a17f22d --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Unit/WorkShop/CategoryTest.php @@ -0,0 +1,68 @@ + 'blueprints', + 'heading' => 'Blueprints', + 'subtitle' => 'Working applications you can clone, run and pull apart.', + 'listings' => [ + [ + 'name' => 'Symfony Backoffice Blueprint', + 'slug' => 'symfony-backoffice', + 'blurb' => 'A working Symfony back office built on Flow PHP.', + 'features' => ['Schema drives the database, API and imports'], + 'available' => true, + 'price' => '$59', + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => 'work_shop_blueprint_symfony', + 'image' => 'images/work-shop/symfony-backoffice-app.png', + ], + ], + ]); + + static::assertSame('blueprints', $category->id); + static::assertSame('Blueprints', $category->heading); + static::assertSame('Working applications you can clone, run and pull apart.', $category->subtitle); + static::assertCount(1, $category->listings); + static::assertSame('symfony-backoffice', $category->listings[0]->slug); + static::assertSame('Blueprints', $category->listings[0]->category); + } + + public function test_from_array_rejects_a_category_without_listings_key(): void + { + $this->expectException(InvalidTypeException::class); + + Category::fromArray([ + 'id' => 'blueprints', + 'heading' => 'Blueprints', + 'subtitle' => 'Working applications you can clone, run and pull apart.', + ]); + } + + public function test_from_array_rejects_an_invalid_listing(): void + { + $this->expectException(InvalidTypeException::class); + + Category::fromArray([ + 'id' => 'blueprints', + 'heading' => 'Blueprints', + 'subtitle' => 'Working applications you can clone, run and pull apart.', + 'listings' => [ + ['name' => 'Broken'], + ], + ]); + } +} diff --git a/web/landing/tests/Flow/Website/Tests/Unit/WorkShop/ListingTest.php b/web/landing/tests/Flow/Website/Tests/Unit/WorkShop/ListingTest.php new file mode 100644 index 000000000..321ec3841 --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Unit/WorkShop/ListingTest.php @@ -0,0 +1,97 @@ + 'Sponsoring — 6 Months', + 'slug' => 'sponsoring-6-months', + 'blurb' => 'The same sponsorship in one payment every six months.', + 'features' => ['Everything from the monthly sponsorship', 'Save 3% vs paying monthly'], + 'available' => true, + 'price' => '$29', + 'price_note' => 'save 3%', + 'badge' => 'Best value', + 'highlight' => true, + 'route' => 'work_shop_sponsoring_6m', + 'image' => 'images/work-shop/sponsoring-6m.svg', + ], 'Sponsoring'); + + static::assertSame('Sponsoring — 6 Months', $listing->name); + static::assertSame('sponsoring-6-months', $listing->slug); + static::assertSame('Sponsoring', $listing->category); + static::assertSame('The same sponsorship in one payment every six months.', $listing->blurb); + static::assertSame( + ['Everything from the monthly sponsorship', 'Save 3% vs paying monthly'], + $listing->features, + ); + static::assertTrue($listing->available); + static::assertSame('$29', $listing->price); + static::assertSame('save 3%', $listing->priceNote); + static::assertSame('Best value', $listing->badge); + static::assertTrue($listing->highlight); + static::assertSame('work_shop_sponsoring_6m', $listing->route); + static::assertSame('images/work-shop/sponsoring-6m.svg', $listing->image); + } + + public function test_from_array_accepts_null_for_optional_fields(): void + { + $listing = Listing::fromArray([ + 'name' => 'More Blueprints', + 'slug' => 'more-blueprints', + 'blurb' => 'More are being built.', + 'features' => ['Fully tested and statically analysed'], + 'available' => false, + 'price' => null, + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => null, + 'image' => null, + ], 'Blueprints'); + + static::assertNull($listing->price); + static::assertNull($listing->priceNote); + static::assertNull($listing->badge); + static::assertNull($listing->route); + static::assertNull($listing->image); + } + + public function test_from_array_rejects_missing_keys(): void + { + $this->expectException(InvalidTypeException::class); + + Listing::fromArray([ + 'name' => 'Consulting', + 'slug' => 'consulting', + ], 'Consulting'); + } + + public function test_from_array_rejects_invalid_types(): void + { + $this->expectException(InvalidTypeException::class); + + Listing::fromArray([ + 'name' => 'Consulting', + 'slug' => 'consulting', + 'blurb' => 'Blurb', + 'features' => 'not-a-list', + 'available' => true, + 'price' => null, + 'price_note' => null, + 'badge' => null, + 'highlight' => false, + 'route' => null, + 'image' => null, + ], 'Consulting'); + } +} diff --git a/web/landing/tests/Flow/Website/Tests/Unit/WorkShop/ListingsTest.php b/web/landing/tests/Flow/Website/Tests/Unit/WorkShop/ListingsTest.php new file mode 100644 index 000000000..d07752f71 --- /dev/null +++ b/web/landing/tests/Flow/Website/Tests/Unit/WorkShop/ListingsTest.php @@ -0,0 +1,66 @@ + $category->id, (new Listings())->all()), + ); + } + + public function test_every_listing_has_a_unique_slug(): void + { + $slugs = []; + + foreach ((new Listings())->all() as $category) { + foreach ($category->listings as $listing) { + $slugs[] = $listing->slug; + } + } + + static::assertSame($slugs, array_values(array_unique($slugs))); + } + + public function test_every_available_listing_has_a_route(): void + { + foreach ((new Listings())->all() as $category) { + foreach ($category->listings as $listing) { + if ($listing->available) { + static::assertNotNull($listing->route, $listing->slug); + static::assertNotNull($listing->image, $listing->slug); + } + } + } + } + + public function test_find_by_slug_returns_the_listing_with_its_category(): void + { + $listing = (new Listings())->findBySlug('claude-skills'); + + static::assertSame('Claude Code Skills', $listing->name); + static::assertSame('AI', $listing->category); + static::assertSame('$10', $listing->price); + } + + public function test_find_by_slug_throws_for_an_unknown_slug(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Listing not found: unknown'); + + (new Listings())->findBySlug('unknown'); + } +}