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