Commit 902f959b authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat: add schema.org json-ld objects to podcasts, episodes, posts and comments pages

- refactor meta-tags by generating them in the controller and injecting them into the views
- use
`melbahja/seo` library to build opengraph and twitter meta-tags + schema.org objects
parent 5c529a83
......@@ -435,6 +435,8 @@ class App extends BaseConfig
*/
public string $siteName = 'Castopod';
public string $siteTitleSeparator = ' | ';
public string $siteDescription = 'Castopod Host is an open-source hosting platform made for podcasters who want engage and interact with their audience.';
/**
......
<?php
declare(strict_types=1);
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Embed extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Embeddable player config
* --------------------------------------------------------------------------
*/
public int $width = 600;
public int $height = 144;
}
......@@ -180,10 +180,10 @@ $routes->group('@(:podcastHandle)', function ($routes): void {
$routes->get('/credits', 'CreditsController', [
'as' => 'credits',
]);
$routes->get('/map', 'MapMarkerController', [
$routes->get('/map', 'MapController', [
'as' => 'map',
]);
$routes->get('/episodes-markers', 'MapMarkerController::getEpisodesMarkers', [
$routes->get('/episodes-markers', 'MapController::getEpisodesMarkers', [
'as' => 'episodes-markers',
]);
$routes->get('/pages/(:slug)', 'PageController/$1', [
......
......@@ -20,7 +20,7 @@ class ActorController extends FediverseActorController
/**
* @var string[]
*/
protected $helpers = ['auth', 'svg', 'components', 'misc'];
protected $helpers = ['auth', 'svg', 'components', 'misc', 'seo'];
public function follow(): string
{
......@@ -34,6 +34,8 @@ class ActorController extends FediverseActorController
if (! ($cachedView = cache($cacheName))) {
helper(['form', 'components', 'svg']);
$data = [
// @phpstan-ignore-next-line
'metatags' => get_follow_metatags($this->actor),
'actor' => $this->actor,
];
......
......@@ -28,7 +28,7 @@ class BaseController extends Controller
ResponseInterface $response,
LoggerInterface $logger
): void {
$this->helpers = array_merge($this->helpers, ['auth', 'svg', 'components', 'misc']);
$this->helpers = array_merge($this->helpers, ['auth', 'svg', 'components', 'misc', 'seo']);
// Do Not Edit This Line
parent::initController($request, $response, $logger);
......
......@@ -165,11 +165,12 @@ class CreditsController extends BaseController
}
$data = [
'metatags' => get_page_metatags($page),
'page' => $page,
'credits' => $credits,
];
$found = view('credits', $data);
$found = view('pages/credits', $data);
cache()
->save($cacheName, $found, DECADE);
......
......@@ -95,6 +95,7 @@ class EpisodeCommentController extends BaseController
if (! ($cachedView = cache($cacheName))) {
$data = [
'metatags' => get_episode_comment_metatags($this->comment),
'podcast' => $this->podcast,
'actor' => $this->actor,
'episode' => $this->episode,
......
......@@ -77,6 +77,7 @@ class EpisodeController extends BaseController
if (! ($cachedView = cache($cacheName))) {
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
];
......@@ -115,6 +116,7 @@ class EpisodeController extends BaseController
if (! ($cachedView = cache($cacheName))) {
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
];
......@@ -220,20 +222,21 @@ class EpisodeController extends BaseController
$oembed->addChild('author_name', $this->podcast->title);
$oembed->addChild('author_url', $this->podcast->link);
$oembed->addChild('thumbnail', $this->episode->cover->large_url);
$oembed->addChild('thumbnail_width', config('Images')->podcastCoverSizes['large'][0]);
$oembed->addChild('thumbnail_height', config('Images')->podcastCoverSizes['large'][1]);
$oembed->addChild('thumbnail_width', (string) config('Images')->podcastCoverSizes['large'][0]);
$oembed->addChild('thumbnail_height', (string) config('Images')->podcastCoverSizes['large'][1]);
$oembed->addChild(
'html',
htmlentities(
'<iframe src="' .
$this->episode->embed_url .
'" width="100%" height="144" frameborder="0" scrolling="no"></iframe>',
'" width="100%" height="' . config('Embed')->height . '" frameborder="0" scrolling="no"></iframe>',
),
);
$oembed->addChild('width', '600');
$oembed->addChild('height', '144');
$oembed->addChild('width', (string) config('Embed')->width);
$oembed->addChild('height', (string) config('Embed')->height);
return $this->response->setXML((string) $oembed);
// @phpstan-ignore-next-line
return $this->response->setXML($oembed);
}
/**
......
......@@ -36,6 +36,7 @@ class HomeController extends BaseController
// default behavior: list all podcasts on home page
$data = [
'metatags' => get_home_metatags(),
'podcasts' => $allPodcasts,
];
......
......@@ -13,7 +13,7 @@ namespace App\Controllers;
use App\Models\EpisodeModel;
use CodeIgniter\HTTP\ResponseInterface;
class MapMarkerController extends BaseController
class MapController extends BaseController
{
public function index(): string
{
......@@ -21,7 +21,7 @@ class MapMarkerController extends BaseController
->getLocale();
$cacheName = "page_map_{$locale}";
if (! ($found = cache($cacheName))) {
$found = view('map', [], [
$found = view('pages/map', [], [
'cache' => DECADE,
'cache_name' => $cacheName,
]);
......
......@@ -40,10 +40,11 @@ class PageController extends BaseController
$cacheName = "page-{$this->page->slug}";
if (! ($found = cache($cacheName))) {
$data = [
'metatags' => get_page_metatags($this->page),
'page' => $this->page,
];
$found = view('page', $data);
$found = view('pages/page', $data);
// The page cache is set to a decade so it is deleted manually upon page update
cache()
......
......@@ -80,6 +80,7 @@ class PodcastController extends BaseController
if (! ($cachedView = cache($cacheName))) {
$data = [
'metatags' => get_podcast_metatags($this->podcast, 'activity'),
'podcast' => $this->podcast,
'posts' => (new PostModel())->getActorPublishedPosts($this->podcast->actor_id),
];
......@@ -125,6 +126,7 @@ class PodcastController extends BaseController
if (! ($cachedView = cache($cacheName))) {
$data = [
'metatags' => get_podcast_metatags($this->podcast, 'about'),
'podcast' => $this->podcast,
];
......@@ -240,6 +242,7 @@ class PodcastController extends BaseController
}
$data = [
'metatags' => get_podcast_metatags($this->podcast, 'episodes'),
'podcast' => $this->podcast,
'episodesNav' => $episodesNavigation,
'activeQuery' => $activeQuery,
......
......@@ -35,7 +35,7 @@ class PostController extends FediversePostController
/**
* @var string[]
*/
protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc'];
protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc', 'seo'];
public function _remap(string $method, string ...$params): mixed
{
......@@ -81,6 +81,8 @@ class PostController extends FediversePostController
if (! ($cachedView = cache($cacheName))) {
$data = [
// @phpstan-ignore-next-line
'metatags' => get_post_metatags($this->post),
'post' => $this->post,
'podcast' => $this->podcast,
];
......@@ -233,6 +235,8 @@ class PostController extends FediversePostController
if (! ($cachedView = cache($cacheName))) {
$data = [
// @phpstan-ignore-next-line
'metatags' => get_remote_actions_metatags($this->post, $action),
'podcast' => $this->podcast,
'actor' => $this->actor,
'post' => $this->post,
......
......@@ -25,7 +25,7 @@ if (! function_exists('render_page_links')) {
$links .= anchor(route_to('credits'), lang('Person.credits'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
]);
$links .= anchor(route_to('map'), lang('Page.map'), [
$links .= anchor(route_to('map'), lang('Page.map.title'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
]);
foreach ($pages as $page) {
......
<?php
declare(strict_types=1);
use App\Entities\Actor;
use App\Entities\Episode;
use App\Entities\EpisodeComment;
use App\Entities\Page;
use App\Entities\Podcast;
use App\Entities\Post;
use Melbahja\Seo\MetaTags;
use Melbahja\Seo\Schema;
use Melbahja\Seo\Schema\Thing;
/**
* @copyright 2021 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (! function_exists('get_podcast_metatags')) {
function get_podcast_metatags(Podcast $podcast, string $page): string
{
$schema = new Schema(
new Thing('PodcastSeries', [
'name' => $podcast->title,
'url' => url_to('podcast-activity', $podcast->handle),
'image' => $podcast->cover->feed_url,
'description' => $podcast->description,
'webFeed' => $podcast->feed_url,
'author' => new Thing('Person', [
'name' => $podcast->publisher,
]),
])
);
$metatags = new MetaTags();
$metatags
->title(' ' . $podcast->title . " (@{$podcast->handle})" . ' • ' . lang('Podcast.' . $page))
->description(htmlspecialchars($podcast->description))
->image((string) $podcast->cover->large_url)
->canonical((string) current_url())
->og('image:width', (string) config('Images')->podcastCoverSizes['large'][0])
->og('image:height', (string) config('Images')->podcastCoverSizes['large'][1])
->og('locale', $podcast->language_code)
->og('site_name', service('settings')->get('App.siteName'));
if ($podcast->payment_pointer) {
$metatags->meta('monetization', $podcast->payment_pointer);
}
return '<link type="application/rss+xml" rel="alternate" title="' . $podcast->title . '" href="' . $podcast->feed_url . '" />' . PHP_EOL . $metatags->__toString() . PHP_EOL . $schema->__toString();
}
}
if (! function_exists('get_episode_metatags')) {
function get_episode_metatags(Episode $episode): string
{
$schema = new Schema(
new Thing('PodcastEpisode', [
'url' => url_to('episode', $episode->podcast->handle, $episode->slug),
'name' => $episode->title,
'image' => $episode->cover->feed_url,
'description' => $episode->description,
'datePublished' => $episode->published_at->format(DATE_ISO8601),
'timeRequired' => iso8601_duration($episode->audio_file_duration),
'associatedMedia' => new Thing('MediaObject', [
'contentUrl' => $episode->audio_file_url,
]),
'partOfSeries' => new Thing('PodcastSeries', [
'name' => $episode->podcast->title,
'url' => url_to('podcast-activity', $episode->podcast->handle),
]),
])
);
$metatags = new MetaTags();
$metatags
->title($episode->title)
->description(htmlspecialchars($episode->description))
->image((string) $episode->cover->large_url, 'player')
->canonical($episode->link)
->og('site_name', service('settings')->get('App.siteName'))
->og('image:width', (string) config('Images')->podcastCoverSizes['large'][0])
->og('image:height', (string) config('Images')->podcastCoverSizes['large'][1])
->og('locale', $episode->podcast->language_code)
->og('audio', $episode->audio_file_opengraph_url)
->og('audio:type', $episode->audio_file_mimetype)
->meta('article:published_time', $episode->published_at->format(DATE_ISO8601))
->meta('article:modified_time', $episode->updated_at->format(DATE_ISO8601))
->twitter('audio:partner', $episode->podcast->publisher ?? '')
->twitter('audio:artist_name', $episode->podcast->owner_name)
->twitter('player', $episode->getEmbedUrl('light'))
->twitter('player:width', (string) config('Embed')->width)
->twitter('player:height', (string) config('Embed')->height);
if ($episode->podcast->payment_pointer) {
$metatags->meta('monetization', $episode->podcast->payment_pointer);
}
return $metatags->__toString() . PHP_EOL . '<link rel="alternate" type="application/json+oembed" href="' . base_url(
route_to('episode-oembed-json', $episode->podcast->handle, $episode->slug)
) . '" title="' . $episode->title . ' oEmbed json" />' . PHP_EOL . '<link rel="alternate" type="text/xml+oembed" href="' . base_url(
route_to('episode-oembed-xml', $episode->podcast->handle, $episode->slug)
) . '" title="' . $episode->title . ' oEmbed xml" />' . PHP_EOL . $schema->__toString();
}
}
if (! function_exists('get_post_metatags')) {
function get_post_metatags(Post $post): string
{
$socialMediaPosting = new Thing('SocialMediaPosting', [
'@id' => url_to('post', $post->actor->username, $post->id),
'datePublished' => $post->published_at->format(DATE_ISO8601),
'author' => new Thing('Person', [
'name' => $post->actor->display_name,
'url' => $post->actor->uri,
]),
'text' => $post->message,
]);
if ($post->episode_id !== null) {
$socialMediaPosting->__set('sharedContent', new Thing('Audio', [
'headline' => $post->episode->title,
'url' => $post->episode->link,
'author' => new Thing('Person', [
'name' => $post->episode->podcast->owner_name,
]),
]));
} elseif ($post->preview_card !== null) {
$socialMediaPosting->__set('sharedContent', new Thing('WebPage', [
'headline' => $post->preview_card->title,
'url' => $post->preview_card->url,
'author' => new Thing('Person', [
'name' => $post->preview_card->author_name,
]),
]));
}
$schema = new Schema($socialMediaPosting);
$metatags = new MetaTags();
$metatags
->title(lang('Post.title', [
'actorDisplayName' => $post->actor->display_name,
]))
->description($post->message)
->image($post->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', service('settings')->get('App.siteName'));
return $metatags->__toString() . PHP_EOL . $schema->__toString();
}
}
if (! function_exists('get_episode_comment_metatags')) {
function get_episode_comment_metatags(EpisodeComment $episodeComment): string
{
$schema = new Schema(new Thing('SocialMediaPosting', [
'@id' => url_to(
'episode-comment',
$episodeComment->actor->username,
$episodeComment->episode->slug,
$episodeComment->id
),
'datePublished' => $episodeComment->created_at->format(DATE_ISO8601),
'author' => new Thing('Person', [
'name' => $episodeComment->actor->display_name,
'url' => $episodeComment->actor->uri,
]),
'text' => $episodeComment->message,
'upvoteCount' => $episodeComment->likes_count,
]));
$metatags = new MetaTags();
$metatags
->title(lang('Comment.title', [
'actorDisplayName' => $episodeComment->actor->display_name,
'episodeTitle' => $episodeComment->episode->title,
]))
->description($episodeComment->message)
->image($episodeComment->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', service('settings')->get('App.siteName'));
return $metatags->__toString() . PHP_EOL . $schema->__toString();
}
}
if (! function_exists('get_follow_metatags')) {
function get_follow_metatags(Actor $actor): string
{
$metatags = new MetaTags();
$metatags
->title(lang('Podcast.followTitle', [
'actorDisplayName' => $actor->display_name,
]))
->description($actor->summary)
->image($actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', service('settings')->get('App.siteName'));
return $metatags->__toString();
}
}
if (! function_exists('get_remote_actions_metatags')) {
function get_remote_actions_metatags(Post $post, string $action): string
{
$metatags = new MetaTags();
$metatags
->title(lang('Fediverse.' . $action . '.title', [
'actorDisplayName' => $post->actor->display_name,
],))
->description($post->message)
->image($post->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', service('settings')->get('App.siteName'));
return $metatags->__toString();
}
}
if (! function_exists('get_home_metatags')) {
function get_home_metatags(): string
{
$metatags = new MetaTags();
$metatags
->title(service('settings')->get('App.siteName'))
->description(service('settings')->get('App.siteDescription'))
->image(service('settings')->get('App.siteIcon')['512'])
->canonical((string) current_url())
->og('site_name', service('settings')->get('App.siteName'));
return $metatags->__toString();
}
}
if (! function_exists('get_page_metatags')) {
function get_page_metatags(Page $page): string
{
$metatags = new MetaTags();
$metatags
->title(
$page->title . service('settings')->get('App.siteTitleSeparator') . service(
'settings'
)->get('App.siteName')
)
->description(service('settings')->get('App.siteDescription'))
->image(service('settings')->get('App.siteIcon')['512'])
->canonical((string) current_url())
->og('site_name', service('settings')->get('App.siteName'));
return $metatags->__toString();
}
}
if (! function_exists('iso8601_duration')) {
// From https://stackoverflow.com/a/40761380
function iso8601_duration(float $seconds): string
{
$days = floor($seconds / 86400);
$seconds %= 86400;
$hours = floor($seconds / 3600);
$seconds %= 3600;
$minutes = floor($seconds / 60);
$seconds %= 60;
return sprintf('P%dDT%dH%dM%dS', $days, $hours, $minutes, $seconds);
}
}
......@@ -10,21 +10,8 @@ declare(strict_types=1);
return [
'back_to_home' => 'Back to home',
'page' => 'Page',
'all_pages' => 'All pages',
'create' => 'New page',
'go_to_page' => 'Go to page',
'edit' => 'Edit page',
'delete' => 'Delete page',
'form' => [
'title' => 'Title',
'permalink' => 'Permalink',
'content' => 'Content',
'submit_create' => 'Create page',
'submit_edit' => 'Save',
'map' => [
'title' => 'Map',
'description' => 'Discover podcast episodes on {siteName} that are placed on a map! Travel through the map and listen to episodes that talk about specific locations.',
],
'messages' => [
'createSuccess' => 'The page “{pageTitle}” was created successfully!',
],
'map' => 'Map',
];
......@@ -28,8 +28,11 @@ return [
other {<span class="font-semibold">#</span> posts}
}',
'activity' => 'Activity',
'activity_title' => '{podcastTitle} news & activity',
'episodes' => 'Episodes',
'episodes_title' => 'Episodes of {podcastTitle}',
'about' => 'About',
'about_title' => 'About {podcastTitle}',
'sponsor_title' => 'Enjoying the show?',
'sponsor' => 'Sponsor',
'funding_links' => 'Funding links for {podcastTitle}',
......
......@@ -10,21 +10,8 @@ declare(strict_types=1);
return [
'back_to_home' => 'Retour à l’accueil',
'page' => 'Page',
'all_pages' => 'Toutes les pages',
'create' => 'Créer une page',
'go_to_page' => 'Aller à la page',
'edit' => 'Modifier la page',
'delete' => 'Supprimer la page',
'form' => [
'title' => 'Titre',
'permalink' => 'Lien permanent',
'content' => 'Contenu',
'submit_create' => 'Créer la page',
'submit_edit' => 'Enregistrer',
'map' => [
'title' => 'Cartographie',
'description' => 'Découvrez des épisodes de podcast placés sur une carte avec {siteName} ! Voyagez sur une carte du monde et écoutez des épisodes mentionnant des lieux spécifiques.',
],
'messages' => [
'createSuccess' => 'La page {pageTitle} a été créée avec succès !',
],
'map' => 'Cartographie',
];
......@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],