Skip to content
Draft
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
103 changes: 103 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,106 @@ the contents of which needs to be deployed to the `BASE_URL`.
## Pages CI setup

Includes Gitlab CI / Gitlab Pages setup.

## Features

### Custom Social Images

Generate dynamic social images (Open Graph, Twitter Cards) from SVG templates. Each route can render a `.svg` version that is then processed through ImgProxy.

#### How It Works

1. **SVG URLs**: Any Linkable entity can generate an SVG URL using `yassg_svg_url(entity)`
2. **Template Rendering**: The SVG template at `pages/{route}.svg.twig` receives the same context as the HTML page
3. **ImgProxy Processing**: Use `yassg_thumbnail()` to process the SVG through ImgProxy
4. **Build Time**: Images are generated and optimized during the build process

#### Usage in Templates

Define a `social_image` block in your page template that uses `yassg_svg_url()`:

```twig
{# templates/pages/article.html.twig #}
{% extends 'layout.html.twig' %}

{% set article = yassg_find_one_by('articles', {condition: {'item.slug': slug}}) %}

{% block title %}{{ article.title }}{% endblock %}

{% block social_image %}{{ yassg_svg_url(article) }}{% endblock %}

{% block body %}
{# ... #}
{% endblock %}
```
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README documentation states that SVG templates should be created in templates/social/{route}.svg.twig, but the actual controller implementation in DefaultController.php line 47 looks for templates in pages/{route}.svg.twig. This mismatch will cause the documented approach to fail with template not found errors. Either the documentation should be updated to reflect the actual pages/ directory, or the controller should be modified to look in the social/ directory for SVG templates.

Copilot uses AI. Check for mistakes.

In your layout template, use the block to generate meta tags:

```twig
{# templates/layout.html.twig #}
{% block social_image %}{% endblock %}
{% set social_image_url = block('social_image') %}
{% if social_image_url is not empty %}
<meta property="og:image" content="{{ yassg_thumbnail(absolute_url(social_image_url)) }}">
<meta name="twitter:image" content="{{ yassg_thumbnail(absolute_url(social_image_url)) }}">
<meta name="twitter:card" content="summary_large_image">
{% endif %}
```

#### Creating SVG Templates

Create SVG templates in `templates/pages/{route}.svg.twig`:

```svg
{# templates/pages/article.svg.twig #}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#1a202c"/>
<text x="600" y="315" font-family="Arial" font-size="64" fill="#ffffff" text-anchor="middle">
{{ article.title }}
</text>
<text x="600" y="380" font-family="Arial" font-size="24" fill="#cccccc" text-anchor="middle">
{{ article.publishedAt|date('F j, Y') }}
</text>
</svg>
```

The SVG template receives the same variables as the HTML template (article, page, etc.).

Comment on lines +108 to +111
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code example is malformed with incomplete syntax. Lines 107-110 contain a closing code block with dangling code that appears to be leftover from editing. The example should either be removed or completed properly. The fragment "format: 'png'" and closing braces/backticks don't belong to any coherent code example.

Suggested change
```
The SVG template receives the same variables as the HTML template (article, page, etc.).

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in daf3ca2. Removed the malformed code fragment (format: 'png' and extra closing braces/backticks).

#### Making Your Model Linkable

To use `yassg_svg_url()`, your model must implement the `Linkable` interface:

```php
use Sigwin\YASSG\Linkable;

final class Article implements Linkable
{
public string $title;
public string $slug;

public function getLinkRouteName(): string
{
return 'article';
}

public function getLinkRouteParameters(): array
{
return ['slug' => $this->slug];
}
}
```

#### Composing with Other Assets

Since SVG templates can use any Twig functions, you can include other assets:

```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">
<image href="{{ yassg_thumbnail(article.image, {width: 1200, height: 630}) }}" width="1200" height="630"/>
<rect width="1200" height="630" fill="rgba(0,0,0,0.5)"/>
<text x="600" y="315" font-size="64" fill="#fff" text-anchor="middle">
{{ article.title }}
</text>
</svg>
```

1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"presta/sitemap-bundle": "^4.0",
"spatie/commonmark-highlighter": "^3.0",
"symfony/console": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0",
"symfony/expression-language": "^6.4 || ^7.0",
"symfony/filesystem": "^6.4 || ^7.0",
"symfony/finder": "^6.4 || ^7.0",
Expand Down
4 changes: 2 additions & 2 deletions config/routes/web_profiler.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
prefix: /_wdt

web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
prefix: /_profiler
2 changes: 1 addition & 1 deletion config/routes/yassg.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ yassg:

when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error
16 changes: 15 additions & 1 deletion resources/init/demo/src/Model/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,23 @@

namespace App\Model;

final class Page
use Sigwin\YASSG\Linkable;

final class Page implements Linkable
{
public string $slug;
public string $title;
public string $image;

#[\Override]
public function getLinkRouteName(): string
{
return 'demo';
}

#[\Override]
public function getLinkRouteParameters(): array
{
return [];
}
}
8 changes: 8 additions & 0 deletions resources/init/demo/templates/layout.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@

<title>{{ demo_page.title }}</title>

{% block social_image %}{% endblock %}
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type error: The yassg_svg_url() function requires a Linkable parameter, but demo_page is of type App\Model\Page which does not implement the Linkable interface (see resources/init/demo/src/Model/Page.php). This will cause a TypeError at runtime. The Page class needs to implement the Linkable interface with getLinkRouteName() and getLinkRouteParameters() methods.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function yassg_social_image() does not exist in the codebase. The actual function added in LinkableExtension.php is yassg_svg_url(). This will cause a runtime error when the template is rendered. The function call should be changed to yassg_svg_url(demo_page).

Suggested change
{% block social_image %}{% endblock %}
{% set social_image_url = yassg_thumbnail(yassg_svg_url(demo_page), {width: 1200, height: 630, format: 'webp'}) %}

Copilot uses AI. Check for mistakes.
{% set social_image_url = block('social_image') %}
{% if social_image_url is not empty %}
<meta property="og:image" content="{{ yassg_thumbnail(absolute_url(social_image_url)) }}">
<meta name="twitter:image" content="{{ yassg_thumbnail(absolute_url(social_image_url)) }}">
<meta name="twitter:card" content="summary_large_image">
{% endif %}

{{ encore_entry_link_tags('app') }}
</head>
<body>
Expand Down
2 changes: 2 additions & 0 deletions resources/init/demo/templates/pages/demo.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
{# query the database #}
{% set demo_page = yassg_find_one_by('pages', {condition: {'item.slug': 'demo-page'}}) %}

{% block social_image %}{{ yassg_svg_url(demo_page) }}{% endblock %}

{% block body %}

<main class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
Expand Down
30 changes: 30 additions & 0 deletions resources/init/demo/templates/pages/demo.svg.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<defs>
<style>
.title { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 72px; font-weight: bold; fill: #ffffff; }
.subtitle { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 36px; fill: #f0f0f0; }
</style>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>

<rect width="1200" height="630" fill="url(#bgGradient)"/>

<text x="600" y="280" class="title" text-anchor="middle">
{% if demo_page is defined %}
{{ demo_page.title }}
{% else %}
My Awesome Site
{% endif %}
</text>

<text x="600" y="360" class="subtitle" text-anchor="middle">
{% if demo_page is defined and demo_page.description is defined %}
{{ demo_page.description }}
{% else %}
Built with YASSG
{% endif %}
</text>
</svg>
18 changes: 16 additions & 2 deletions src/Bridge/Symfony/Controller/DefaultController.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,23 @@ public function __invoke(RequestStack $requestStack): Response
throw new \LogicException('Invalid request, invalid route attribute');
}

/** @var null|string $filename */
$filename = $request->attributes->get('_filename');
switch (true) {
case $filename === 'index.svg':
$type = 'svg';
$contentType = 'image/svg+xml';
break;
default:
$type = 'html';
$contentType = 'text/html; charset=UTF-8';
}

/** @var string $template */
$template = $request->attributes->get('_template') ?? \sprintf('pages/%1$s.html.twig', $route);
$template = $request->attributes->get('_template') ?? \sprintf('pages/%1$s.%2$s.twig', $route, $type);
$response = $this->render($template, $request->attributes->all());
$response->headers->set('Content-Type', $contentType);

return $this->render($template, $request->attributes->all());
return $response;
}
}
3 changes: 2 additions & 1 deletion src/Bridge/Symfony/Routing/Loader/RouteLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ public function __invoke(): RouteCollection
$path = $route['path'];
$requirements = [];
} else {
// Support both index.html and .svg extension
$path = $route['path'].'/{_filename}';
$requirements = [
'_filename' => 'index\.html',
'_filename' => 'index(\.html|\.svg)',
];
}

Expand Down
6 changes: 5 additions & 1 deletion src/Bridge/Twig/Extension/ImageExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ private function buildImgproxyFilter(array $options): string

private function buildImgproxyUrl(string $path, string $filters): string
{
return \sprintf('%1$s/insecure/%2$s%3$s', $this->imgproxyUrl, $filters, $this->encode('local:///'.mb_ltrim(str_replace($GLOBALS['YASSG_BASEDIR'], '', $path), '/')));
if (! str_contains($path, '://')) {
$path = 'local:///'.mb_ltrim(str_replace($GLOBALS['YASSG_BASEDIR'], '', $path), '/');
}

return \sprintf('%1$s/insecure/%2$s%3$s', $this->imgproxyUrl, $filters, $this->encode($path));
}
}
3 changes: 2 additions & 1 deletion src/Bridge/Twig/Extension/LinkableExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ public function __construct(private readonly UrlGeneratorInterface $generator)
public function getFunctions(): array
{
return [
new TwigFunction('yassg_url', fn (Linkable $linkable) => $this->generator->generate($linkable->getLinkRouteName(), $linkable->getLinkRouteParameters())),
new TwigFunction('yassg_url', fn (Linkable $linkable, array $parameters = []) => $this->generator->generate($linkable->getLinkRouteName(), array_replace($linkable->getLinkRouteParameters(), $parameters))),
new TwigFunction('yassg_svg_url', fn (Linkable $linkable, array $parameters = []) => $this->generator->generate($linkable->getLinkRouteName(), array_replace($linkable->getLinkRouteParameters(), $parameters, ['_filename' => 'index.svg']))),
];
}
}
18 changes: 11 additions & 7 deletions tests/functional/site/compose.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
services:
app:
image: php:8.2-alpine
image: php:8.4-alpine
environment:
BASE_URL: ${BASE_URL}
working_dir: /app
YASSG_SKIP_BUNDLES: Symfony\WebpackEncoreBundle\WebpackEncoreBundle
command: php -S 0.0.0.0:9988 -t /app/tests/functional/site
ports:
- "9988:9988"
volumes:
- .:/app
- ../../../:/app
tmpfs:
- /tmp
webpack:
image: node:20-alpine
image: node:25-alpine
environment:
BASE_URL: ${BASE_URL}
working_dir: /app
Expand All @@ -20,13 +23,14 @@ services:
imgproxy:
image: darthsim/imgproxy:v3.27.2
environment:
- IMGPROXY_BIND=:8090
- IMGPROXY_LOCAL_FILESYSTEM_ROOT=/app
- IMGPROXY_MAX_ANIMATION_FRAMES=60
- IMGPROXY_ENABLE_AVIF_DETECTION=1
- IMGPROXY_ENABLE_WEBP_DETECTION=1
- IMGPROXY_AUTO_AVIF=1
- IMGPROXY_AUTO_WEBP=1
working_dir: /app
ports:
- 8090:8080
- 8090:8090
volumes:
- .:/app:ro
tmpfs:
Expand Down
10 changes: 9 additions & 1 deletion tests/functional/site/templates/layout.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>

<title>{% block title %}{% endblock %}</title>


{% block social_image %}{% endblock %}
{% set social_image_url = block('social_image') %}
{% if social_image_url is not empty %}
<meta property="og:image" content="{{ yassg_thumbnail(absolute_url(social_image_url)) }}">
<meta name="twitter:image" content="{{ yassg_thumbnail(absolute_url(social_image_url)) }}">
<meta name="twitter:card" content="summary_large_image">
{% endif %}

{{ encore_entry_link_tags('app') }}
</head>
<body>
Expand Down
2 changes: 2 additions & 0 deletions tests/functional/site/templates/pages/article.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

{% block title %}{{ article.title }}{% endblock %}

{% block social_image %}{{ yassg_svg_url(article) }}{% endblock %}

{% block body %}
<main class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl">
Expand Down
11 changes: 11 additions & 0 deletions tests/functional/site/templates/pages/article.svg.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% set article = yassg_find_one_by('articles', {condition: {'item.slug': slug}}) %}

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#1a202c"/>
<text x="600" y="315" font-family="Arial, sans-serif" font-size="64" fill="#ffffff" text-anchor="middle">
{{ article.title }}
</text>
<text x="600" y="380" font-family="Arial, sans-serif" font-size="24" fill="#cccccc" text-anchor="middle">
{{ article.publishedAt|date('F j, Y') }}
</text>
</svg>
Loading