Browse Source

setup remote build

imwald
Silberengel 2 months ago
parent
commit
8353ae8c25
  1. 2
      .dockerignore
  2. 7
      .env.dist
  3. 3
      Dockerfile
  4. 53
      README.md
  5. 61
      compose.hub.yaml
  6. 2
      composer.json
  7. 5
      config/packages/framework.yaml
  8. 2
      config/services.yaml
  9. 5
      frankenphp/docker-entrypoint.sh
  10. 81
      src/Controller/ArticleController.php
  11. 46
      src/Controller/DefaultController.php
  12. 23
      src/Twig/Components/Header.php
  13. 7
      src/Twig/Components/Organisms/FeaturedList.php
  14. 7
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php
  15. 10
      templates/base.html.twig
  16. 2
      templates/components/Organisms/FeaturedList.html.twig
  17. 14
      templates/home.html.twig
  18. 61
      templates/pages/article.html.twig
  19. 25
      templates/pages/category.html.twig

2
.dockerignore

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
**/*.php~
**/*.dist.php
**/*.dist
!.env.dist
**/*.cache
**/._*
**/.dockerignore
@ -28,6 +29,7 @@ tests/ @@ -28,6 +29,7 @@ tests/
var/
vendor/
.editorconfig
/.env
.env.*.local
.env.local
.env.local.php

7
.env.dist

@ -17,6 +17,8 @@ @@ -17,6 +17,8 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=9e287f1ad737386dde46d51e80487236
# Comma-separated CIDRs for reverse proxies (used when APP_ENV=prod). Override on the server if needed.
# TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,::1
###< symfony/framework-bundle ###
###> docker ###
# Dev URL: http://127.0.0.1:${HTTP_PORT}/ (override HTTP_PORT/HTTPS_PORT if busy).
@ -32,6 +34,11 @@ MYSQL_PASSWORD=password @@ -32,6 +34,11 @@ MYSQL_PASSWORD=password
# Root password is only used for the bundled database service, see compose.yaml
# skip it, if you use your own
MYSQL_ROOT_PASSWORD=root_password
# Hub deploy: optional full image ref (default silberengel/unfold:latest in compose.hub.yaml).
# UNFOLD_DOCKER_IMAGE=silberengel/unfold:1.0.0
# compose.hub.yaml: default host port is 9080. Use 80 only if nothing else binds it. Loopback-only example:
# HTTP_PUBLISH=127.0.0.1:9080
# HTTP_PUBLISH=80
###< docker ###
###> doctrine/doctrine-bundle ###

3
Dockerfile

@ -96,7 +96,10 @@ RUN rm -Rf frankenphp/ @@ -96,7 +96,10 @@ RUN rm -Rf frankenphp/
RUN set -eux; \
mkdir -p var/cache var/log; \
cp .env.dist .env; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
rm -f .env; \
composer run-script --no-dev post-install-cmd; \
php bin/console asset-map:compile --no-debug; \
chmod +x bin/console; sync;

53
README.md

@ -48,13 +48,60 @@ For development: @@ -48,13 +48,60 @@ For development:
docker compose build
```
For production (using production overrides), set `APP_ENV=prod` in your `.env` file and run:
For production (using production overrides), set `APP_ENV=prod` in your `.env` file, set a strong **`APP_SECRET`**, and run:
```bash
docker compose -f compose.yaml -f compose.prod.yaml build
```
`compose.override.yaml` is meant for local development. For production, always pass **both** compose files for `up` as well, otherwise Docker Compose still merges the dev override (FrankenPHP dev image, port `9080`, etc.):
```bash
docker compose -f compose.yaml -f compose.prod.yaml up -d
```
The production compose file publishes the app on **host port `80`** → container `80` (FrankenPHP / Caddy). Put **TLS and your public hostname** (e.g. `https://blog.imwald.eu`) in front with Apache or nginx as a reverse proxy to `http://127.0.0.1:80` on that host (or another port if you change `compose.prod.yaml`).
Set **`TRUSTED_PROXIES`** to the CIDR of your reverse proxy (defaults in `compose.prod.yaml` cover Docker and private nets; include the proxy’s address if it is elsewhere). In **`APP_ENV=prod`**, `config/packages/framework.yaml` enables **`trusted_proxies`** from that env var so Symfony trusts `X-Forwarded-Proto` / `X-Forwarded-For` from the proxy; adjust the value if generated URLs or secure cookies are wrong behind HTTPS.
### Docker Hub (pre-built image)
To build the production FrankenPHP image and push it (example registry: [`silberengel/unfold`](https://hub.docker.com/r/silberengel/unfold)):
```bash
docker login
# If the server is linux/amd64 and your builder is ARM, set --platform (omit if arch matches).
docker build --platform linux/amd64 --target frankenphp_prod -t silberengel/unfold:latest .
docker push silberengel/unfold:latest
```
Tag a release when you want a pinned version:
```bash
docker tag silberengel/unfold:latest silberengel/unfold:1.0.0
docker push silberengel/unfold:1.0.0
```
**On the remote server** you only need `compose.hub.yaml`, a `.env` with at least **`APP_SECRET`** (and `MYSQL_*` / `MYSQL_ROOT_PASSWORD` if you use the bundled MySQL), and Docker Compose. Copy `compose.hub.yaml` from the repo (or clone once and take that file). The stack publishes the app on **host port `9080`** → container `80` by default (so **`:80` stays free** for Apache/nginx). Point your reverse proxy at `http://127.0.0.1:9080`. To bind only loopback, set **`HTTP_PUBLISH=127.0.0.1:9080`**; to use host port **80** instead, set **`HTTP_PUBLISH=80`**. Override the image with **`UNFOLD_DOCKER_IMAGE=myuser/unfold:1.0.0`** if you use another name or tag.
```bash
docker compose -f compose.hub.yaml pull
docker compose -f compose.hub.yaml up -d
docker compose -f compose.hub.yaml exec php php bin/console doctrine:migrations:migrate --no-interaction
```
The production image must include **compiled asset mapper files** under `public/assets/` (the Docker build runs `asset-map:compile`). If you ever see JS modules blocked because the MIME type is `text/html`, the static files are missing: rebuild and push the image, or run once on the server:
`docker compose -f compose.hub.yaml exec php php bin/console asset-map:compile --no-debug`
The default `compose.hub.yaml` stack includes the **MySQL** service like the main compose file. If you use an external database, remove the `database` service and the `depends_on` block from `compose.hub.yaml`, and set **`DATABASE_URL`** in the `php` service `environment` to your connection string.
**MySQL `1045 Access denied` for `unfold_user`:** The official MySQL image only applies **`MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_ROOT_PASSWORD` on the first start** of an empty data volume. If you change passwords in `.env` later, the files inside the **`database_data` volume** still hold the old users. Either set **`.env`** back to the **original** passwords, or stop the stack and remove the named volume (e.g. `docker compose -f compose.hub.yaml down` then `docker volume rm unfold_database_data`**this deletes all DB data**), then **`up -d`** again with the passwords you want and run **migrations** again.
The repo’s **`cron`** service still expects a local build and bind-mounted source; for Hub deploys, run **`articles:get`** (and any other jobs) from a host **cron** or **systemd timer** calling `docker compose -f compose.hub.yaml exec -T php php bin/console …`.
### Start the Docker containers (development)
### Start the Docker containers
```bash
docker compose up -d
```
@ -68,6 +115,8 @@ Before fetching or displaying articles, make sure your database schema is up to @@ -68,6 +115,8 @@ Before fetching or displaying articles, make sure your database schema is up to
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
```
If you use **`compose.hub.yaml`**, prefix commands with `docker compose -f compose.hub.yaml` (for example `docker compose -f compose.hub.yaml exec php php bin/console …`).
### Fetching Articles
To fetch articles from the default relay for the last two months, run:

61
compose.hub.yaml

@ -0,0 +1,61 @@ @@ -0,0 +1,61 @@
# Run a pre-built production image from Docker Hub (no local PHP image build).
#
# Usage on the server (copy this file + your .env, no app source required):
# docker compose -f compose.hub.yaml pull
# docker compose -f compose.hub.yaml up -d
# docker compose -f compose.hub.yaml exec php php bin/console doctrine:migrations:migrate --no-interaction
#
# Required in .env: APP_SECRET. Set MYSQL_* (or replace DATABASE_URL after editing this file) if you
# use the bundled database. For TLS in front, set TRUSTED_PROXIES to include your reverse proxy CIDR.
#
# Host HTTP port defaults to 9080 (same idea as local dev) so Apache/nginx can keep :80. Override with
# HTTP_PUBLISH=80 or HTTP_PUBLISH=127.0.0.1:9080 in .env if needed.
#
# Build & push (on your machine or CI), e.g.:
# docker build --platform linux/amd64 --target frankenphp_prod -t silberengel/unfold:latest .
# docker push silberengel/unfold:latest
#
# Override image: UNFOLD_DOCKER_IMAGE=myregistry/unfold:1.0.0 docker compose -f compose.hub.yaml up -d
name: unfold
services:
php:
image: ${UNFOLD_DOCKER_IMAGE:-silberengel/unfold:latest}
pull_policy: always
restart: unless-stopped
environment:
APP_ENV: ${APP_ENV:-prod}
APP_SECRET: ${APP_SECRET}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8}
SERVER_NAME: ${SERVER_NAME:-:80}
DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0}&charset=${MYSQL_CHARSET:-utf8mb4}
volumes:
- caddy_data:/data
- caddy_config:/config
ports:
- "${HTTP_PUBLISH:-9080}:80/tcp"
depends_on:
database:
condition: service_healthy
database:
image: mysql:${MYSQL_VERSION:-8.0}
restart: unless-stopped
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db}
MYSQL_USER: ${MYSQL_USER:-unfold_user}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-password}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/mysql:rw
volumes:
caddy_data:
caddy_config:
database_data:

2
composer.json

@ -102,7 +102,7 @@ @@ -102,7 +102,7 @@
"docker": true
},
"runtime": {
"dotenv_overload": true
"dotenv_overload": false
}
},
"require-dev": {

5
config/packages/framework.yaml

@ -10,11 +10,14 @@ framework: @@ -10,11 +10,14 @@ framework:
cookie_samesite: lax
cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session'
trusted_headers: ['forwarded', 'x-forwarded-for', 'x-forwarded-proto']
# trusted_proxies: '%env(TRUSTED_PROXIES)%'
#trusted_proxies: 'symfony,REMOTE_ADDR'
#esi: true
#fragments: true
when@prod:
framework:
trusted_proxies: '%env(TRUSTED_PROXIES)%'
when@test:
framework:
test: true

2
config/services.yaml

@ -8,6 +8,8 @@ imports: @@ -8,6 +8,8 @@ imports:
parameters:
footer_links: '%kernel.project_dir%/config/unfold.yaml'
# Default when TRUSTED_PROXIES is unset (override in .env / Compose for real deployments).
env(TRUSTED_PROXIES): '127.0.0.0/8,::1'
services:
# default configuration for services in *this* file

5
frankenphp/docker-entrypoint.sh

@ -16,7 +16,7 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then @@ -16,7 +16,7 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
composer require "php:>=$PHP_VERSION" runtime/frankenphp-symfony
composer config --json extra.symfony.docker 'true'
if grep -q ^DATABASE_URL= .env; then
if [ -f .env ] && grep -q ^DATABASE_URL= .env; then
echo 'To finish the installation please press Ctrl+C to stop Docker Compose and run: docker compose up --build -d --wait'
sleep infinity
fi
@ -26,7 +26,8 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then @@ -26,7 +26,8 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
composer install --prefer-dist --no-progress --no-interaction
fi
if grep -q ^DATABASE_URL= .env; then
# DATABASE_URL from Compose / k8s env, or from a local .env file (dev bind-mount).
if [ -n "${DATABASE_URL:-}" ] || { [ -f .env ] && grep -q ^DATABASE_URL= .env; }; then
echo 'Waiting for database to be ready...'
ATTEMPTS_LEFT_TO_REACH_DATABASE=60
until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do

81
src/Controller/ArticleController.php

@ -208,45 +208,64 @@ class ArticleController extends AbstractController @@ -208,45 +208,64 @@ class ArticleController extends AbstractController
CacheItemPoolInterface $articlesCache
): Response {
$data = $request->getContent();
// descriptor is an object with properties type, identifier and data
// if type === 'nevent', identifier is the event id
// if type === 'naddr', identifier is the naddr
// if type === 'nprofile', identifier is the npub
$descriptor = json_decode($data);
$previewData = [];
// if nprofile, get from redis cache
if ($descriptor->type === 'nprofile') {
$hint = json_decode($descriptor->decoded);
$key = new Key();
$npub = $key->convertPublicKeyToBech32($hint->pubkey);
$metadata = $cacheService->getMetadata($npub);
$metadata->npub = $npub;
$metadata->pubkey = $hint->pubkey;
$metadata->type = 'nprofile';
// Render the NostrPreviewContent component with the preview data
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $metadata
]);
} else {
// For nevent or naddr, fetch the event data
try {
$previewData = $nostrClient->getEventFromDescriptor($descriptor);
$previewData->type = $descriptor->type; // Add type to the preview data
// Render the NostrPreviewContent component with the preview data
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $previewData
]);
} catch (\Exception $e) {
$html = '<span>Error fetching preview: ' . htmlspecialchars($e->getMessage()) . '</span>';
}
if (!\is_object($descriptor) || !isset($descriptor->type)) {
return new Response(
'<span class="text-subtle">Invalid preview request.</span>',
Response::HTTP_OK,
['Content-Type' => 'text/html; charset=UTF-8']
);
}
$html = '';
try {
if ($descriptor->type === 'nprofile') {
if (!isset($descriptor->decoded) || !\is_string($descriptor->decoded)) {
$html = '<span class="text-subtle">Profile preview unavailable.</span>';
} else {
$hint = json_decode($descriptor->decoded);
if (!\is_object($hint) || !isset($hint->pubkey)) {
$html = '<span class="text-subtle">Profile preview unavailable.</span>';
} else {
$key = new Key();
$npub = $key->convertPublicKeyToBech32($hint->pubkey);
$metadata = $cacheService->getMetadata($npub);
$metadata->npub = $npub;
$metadata->pubkey = $hint->pubkey;
$metadata->type = 'nprofile';
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $metadata,
]);
}
}
} elseif (!isset($descriptor->decoded)) {
$html = '<span class="text-subtle">Preview unavailable (missing data).</span>';
} else {
try {
$previewData = $nostrClient->getEventFromDescriptor($descriptor);
} catch (\Throwable $e) {
$previewData = null;
$html = '<span class="text-subtle">Error fetching preview: '.htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</span>';
}
if ($html === '' && $previewData === null) {
$html = '<span class="text-subtle">No event found on the default relay for this preview.</span>';
} elseif ($html === '' && \is_object($previewData)) {
$previewData->type = $descriptor->type;
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $previewData,
]);
}
}
} catch (\Throwable $e) {
$html = '<span class="text-subtle">Preview error: '.htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</span>';
}
return new Response(
$html,
Response::HTTP_OK,
['Content-Type' => 'text/html']
['Content-Type' => 'text/html; charset=UTF-8']
);
}

46
src/Controller/DefaultController.php

@ -14,6 +14,7 @@ use Symfony\Component\HttpFoundation\RequestStack; @@ -14,6 +14,7 @@ use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Psr\Log\LoggerInterface;
class DefaultController extends AbstractController
@ -33,17 +34,28 @@ class DefaultController extends AbstractController @@ -33,17 +34,28 @@ class DefaultController extends AbstractController
{
$npub = $this->params->get('npub');
$dTag = $this->params->get('d_tag');
// Key must match {@see Header} — `magazine_root_` avoids stale `null` entries from the old Header callback.
$cacheKey = 'magazine_root_'.$dTag;
$mag = $this->cache->get($cacheKey, function ($item) use ($npub, $dTag) {
$item->expiresAfter(300); // 5 minutes
return $this->nostrClient->getMagazineIndex($npub, $dTag);
});
// Key must match {@see Header}. Throw from the cache callback when the index is missing so `null`
// is not stored under this key (same pattern as {@see CategoryLink} / per-category cache).
$cacheKey = 'magazine_root_v2_'.$dTag;
try {
$mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) {
$item->expiresAfter(300);
$mag = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($mag === null) {
throw new \RuntimeException('Magazine root index not found for '.$dTag);
}
return $mag;
});
} catch (\Throwable) {
return $this->render('home.html.twig', [
'indices' => [],
]);
}
// Handle case when magazine is not found
if ($mag === null) {
return $this->render('home.html.twig', [
'indices' => []
'indices' => [],
]);
}
@ -98,15 +110,16 @@ class DefaultController extends AbstractController @@ -98,15 +110,16 @@ class DefaultController extends AbstractController
}
if (!empty($coordinates)) {
$slugs = array_map(function($coordinate) {
$parts = explode(':', $coordinate, 3);
return end($parts);
$slugs = array_map(static function ($coordinate) {
$parts = explode(':', (string) $coordinate, 3);
return trim((string) end($parts));
}, $coordinates);
$slugs = array_filter($slugs);
$slugs = array_values(array_filter($slugs, static fn (string $s): bool => $s !== ''));
$articles = $articleRepository->findBySlugsCriteria($slugs);
$slugMap = [];
foreach ($articles as $item) {
$slug = $item->getSlug();
$slug = trim((string) $item->getSlug());
if ($slug !== '') {
if (!isset($slugMap[$slug])) {
$slugMap[$slug] = $item;
@ -119,9 +132,10 @@ class DefaultController extends AbstractController @@ -119,9 +132,10 @@ class DefaultController extends AbstractController
}
}
foreach ($coordinates as $coordinate) {
$parts = explode(':', $coordinate, 3);
if (isset($slugMap[end($parts)])) {
$list[] = $slugMap[end($parts)];
$parts = explode(':', (string) $coordinate, 3);
$slugKey = trim((string) end($parts));
if ($slugKey !== '' && isset($slugMap[$slugKey])) {
$list[] = $slugMap[$slugKey];
}
}
}

23
src/Twig/Components/Header.php

@ -26,13 +26,24 @@ class Header @@ -26,13 +26,24 @@ class Header
) {
$dTag = (string) $this->params->get('d_tag');
$npub = (string) $this->params->get('npub');
// Same key as {@see DefaultController::index()} — must load the real index (not cache `null`).
$cacheKey = 'magazine_root_'.$dTag;
$mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) {
$item->expiresAfter(300);
// Same key as {@see DefaultController::index()}. If the relay returns nothing, throw from the
// callback so Symfony does not persist `null` — otherwise categories vanish until TTL (~5 min).
$cacheKey = 'magazine_root_v2_'.$dTag;
try {
$mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) {
$item->expiresAfter(300);
$mag = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($mag === null) {
throw new \RuntimeException('Magazine root index not found for '.$dTag);
}
return $mag;
});
} catch (\Throwable) {
$this->cats = [];
return $this->nostrClient->getMagazineIndex($npub, $dTag);
});
return;
}
if ($mag === null) {
$this->cats = [];

7
src/Twig/Components/Organisms/FeaturedList.php

@ -70,7 +70,7 @@ final class FeaturedList @@ -70,7 +70,7 @@ final class FeaturedList
}
if (($tag[0] ?? null) === 'a' && isset($tag[1])) {
$segs = explode(':', (string) $tag[1], 3);
$slugs[] = end($segs);
$slugs[] = trim((string) end($segs));
if (\count($slugs) >= 5) {
break;
}
@ -89,7 +89,7 @@ final class FeaturedList @@ -89,7 +89,7 @@ final class FeaturedList
$slugMap = [];
foreach ($articles as $article) {
$articleSlug = $article->getSlug();
$articleSlug = trim((string) $article->getSlug());
if ($articleSlug !== '') {
if (!isset($slugMap[$articleSlug])) {
$slugMap[$articleSlug] = $article;
@ -101,7 +101,8 @@ final class FeaturedList @@ -101,7 +101,8 @@ final class FeaturedList
$orderedList = [];
foreach ($slugs as $articleSlug) {
if (isset($slugMap[$articleSlug])) {
$articleSlug = trim((string) $articleSlug);
if ($articleSlug !== '' && isset($slugMap[$articleSlug])) {
$orderedList[] = $slugMap[$articleSlug];
}
}

7
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php

@ -9,8 +9,6 @@ use nostriphant\NIP19\Bech32; @@ -9,8 +9,6 @@ use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr;
use nostriphant\NIP19\Data\NEvent;
use nostriphant\NIP19\Data\NProfile;
use nostriphant\NIP19\Data\NPub;
class NostrSchemeParser implements InlineParserInterface
{
@ -38,9 +36,8 @@ class NostrSchemeParser implements InlineParserInterface @@ -38,9 +36,8 @@ class NostrSchemeParser implements InlineParserInterface
switch ($decoded->type) {
case 'npub':
/** @var NPub $decoded */
$decoded = $decoded->data;
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $decoded->data->data));
// Use the decoded bech32 (npub1…). NPub::$data is the hex pubkey; NostrMentionLink /author routes expect npub1…
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $bechEncoded));
break;
case 'nprofile':
/** @var NProfile $decodedProfile */

10
templates/base.html.twig

@ -4,7 +4,10 @@ @@ -4,7 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ website_name }}{% endblock %}</title>
<meta name="description" content="{{ website_description }}">
{# Unfurlers often use the first meta description; pages override this block. #}
{% block meta_description %}
<meta name="description" content="{{ website_description|e('html_attr') }}">
{% endblock %}
<link rel="icon" type="image/png" href="{{ asset('icons/favicon-96x96.png') }}" sizes="96x96" />
<link rel="icon" type="image/x-icon" href="{{ asset('icons/favicon.ico') }}" />
<link rel="shortcut icon" href="{{ asset('icons/favicon.ico') }}" />
@ -14,7 +17,10 @@ @@ -14,7 +17,10 @@
<link rel="manifest" href="{{ path('pwa_manifest') }}">
{% block ogtags %}
<meta property="og:image" content="{{ absolute_url(asset('og-image.jpg')) }}" />
<meta property="og:type" content="website">
<meta property="og:description" content="{{ website_description|e('html_attr') }}">
<meta property="og:image" content="{{ absolute_url(asset('og-image.jpg'))|e('html_attr') }}">
<meta property="og:site_name" content="{{ website_name|e('html_attr') }}">
{% endblock %}
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}

2
templates/components/Organisms/FeaturedList.html.twig

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<div>
{% if list %}
<div class="featured-cat hidden">
<div class="featured-cat">
<small><b>{{ title }}</b></small>
</div>
<div {{ attributes }}>

14
templates/home.html.twig

@ -1,5 +1,19 @@ @@ -1,5 +1,19 @@
{% extends 'base.html.twig' %}
{% block ogtags %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
<meta property="og:type" content="website">
<meta property="og:url" content="{{ url('home') }}">
<meta property="og:title" content="{{ website_name|e('html_attr') }}">
<meta property="og:description" content="{{ website_description|e('html_attr') }}">
<meta property="og:image" content="{{ _og_image|e('html_attr') }}">
<meta property="og:site_name" content="{{ website_name|e('html_attr') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ website_name|e('html_attr') }}">
<meta name="twitter:description" content="{{ website_description|e('html_attr') }}">
<meta name="twitter:image" content="{{ _og_image|e('html_attr') }}">
{% endblock %}
{% block nav %}
{% endblock %}

61
templates/pages/article.html.twig

@ -1,16 +1,61 @@ @@ -1,16 +1,61 @@
{% extends 'base.html.twig' %}
{% block title %}{{ article.title|trim }} — {{ website_name }}{% endblock %}
{% block meta_description %}
{% set _desc = article.summary|default('')|striptags|u.truncate(159, '…') %}
<meta name="description" content="{{ _desc|e('html_attr') }}">
{% endblock %}
{% block ogtags %}
<meta property="og:title" content="{{ article.title }}">
<meta property="og:type" content="article">
<meta property="og:url" content="{{ app.request.uri }}">
{% if article.image %}
<meta property="og:image" content="{{ article.image }}">
{# Upstream main only outputs og:image when article.image is set — unfurlers often show a blank card. Always set an absolute image + JSON-LD. #}
{% set _img = article.image|default('')|trim %}
{% if _img == '' %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
{% set _og_default_dims = true %}
{% elseif _img starts with '//' %}
{% set _og_image = 'https:' ~ _img %}
{% set _og_default_dims = false %}
{% else %}
<meta property="og:image" content="{{ absolute_url(asset('og-image.jpg')) }}">
{% set _og_image = absolute_url(_img) %}
{% set _og_default_dims = false %}
{% endif %}
{% set _desc = article.summary|default('')|striptags|u.truncate(159, '…') %}
{% set _canonical = url('article-slug', {slug: article.slug}) %}
{% set _author_name = '' %}
{% if author is defined and author %}
{% set _author_name = attribute(author, 'name')|default(attribute(author, 'display_name')|default('')) %}
{% endif %}
<meta property="og:title" content="{{ article.title|e('html_attr') }}">
<meta property="og:type" content="article">
<meta property="og:url" content="{{ _canonical|e('html_attr') }}">
<meta property="og:image" content="{{ _og_image|e('html_attr') }}">
{% if _og_image starts with 'https://' %}
<meta property="og:image:secure_url" content="{{ _og_image|e('html_attr') }}">
{% endif %}
{% if _og_default_dims %}
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{% endif %}
<meta property="og:description" content="{{ article.summary|striptags|u.truncate(159,'…')|e }}">
<meta property="og:site_name" content="{{ website_name }}">
<meta property="og:description" content="{{ _desc|e('html_attr') }}">
<meta property="og:site_name" content="{{ website_name|e('html_attr') }}">
<link rel="canonical" href="{{ _canonical|e('html_attr') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ article.title|e('html_attr') }}">
<meta name="twitter:description" content="{{ _desc|e('html_attr') }}">
<meta name="twitter:image" content="{{ _og_image|e('html_attr') }}">
<script type="application/ld+json">{{ {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'headline': article.title,
'description': _desc,
'image': _og_image,
'url': _canonical,
'datePublished': (article.publishedAt ?? article.createdAt)|date('c'),
'mainEntityOfPage': {'@type': 'WebPage', '@id': _canonical},
'publisher': {'@type': 'Organization', 'name': website_name},
'author': {'@type': 'Person', 'name': _author_name != '' ? _author_name : (npub|default('Author'))}
}|json_encode|raw }}</script>
{% endblock %}
{% block body %}

25
templates/pages/category.html.twig

@ -1,12 +1,27 @@ @@ -1,12 +1,27 @@
{% extends 'base.html.twig' %}
{% block title %}{{ (category.title|default(''))|trim != '' ? category.title|trim ~ ' — ' ~ website_name : website_name }}{% endblock %}
{% block meta_description %}
{% set _summary = category.summary|default('')|striptags|u.truncate(159, '…') %}
<meta name="description" content="{{ (_summary != '' ? _summary : (category.title|default('')|striptags))|e('html_attr') }}">
{% endblock %}
{% block ogtags %}
<meta property="og:title" content="{{ category.title|default('') }}">
{% set _title = category.title|default('') %}
{% set _summary = category.summary|default('')|striptags|u.truncate(159, '…') %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
<meta property="og:title" content="{{ _title|e('html_attr') }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ app.request.uri }}">
<meta property="og:description" content="{{ category.summary|default('') }}">
<meta property="og:image" content="{{ absolute_url(asset('og-image.jpg')) }}">
<meta property="og:site_name" content="{{ website_name }}">
<meta property="og:url" content="{{ app.request.attributes.get('_route') == 'articles' ? url('articles') : url('magazine-category', {slug: app.request.attributes.get('slug')}) }}">
<meta property="og:description" content="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}">
<meta property="og:image" content="{{ _og_image|e('html_attr') }}">
<meta property="og:site_name" content="{{ website_name|e('html_attr') }}">
<link rel="canonical" href="{{ app.request.attributes.get('_route') == 'articles' ? url('articles') : url('magazine-category', {slug: app.request.attributes.get('slug')}) }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ _title|e('html_attr') }}">
<meta name="twitter:description" content="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}">
<meta name="twitter:image" content="{{ _og_image|e('html_attr') }}">
{% endblock %}
{% block nav %}

Loading…
Cancel
Save