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
This commit is contained in:
Yassine Doghri 2021-11-12 16:31:35 +00:00
parent 5c529a83aa
commit 902f959b30
45 changed files with 449 additions and 354 deletions

View File

@ -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.';
/**

19
app/Config/Embed.php Normal file
View File

@ -0,0 +1,19 @@
<?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;
}

View File

@ -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', [

View File

@ -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,
];

View File

@ -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);

View File

@ -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);

View File

@ -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,

View File

@ -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);
}
/**

View File

@ -36,6 +36,7 @@ class HomeController extends BaseController
// default behavior: list all podcasts on home page
$data = [
'metatags' => get_home_metatags(),
'podcasts' => $allPodcasts,
];

View File

@ -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,
]);

View File

@ -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()

View File

@ -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,

View File

@ -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,

View File

@ -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) {

275
app/Helpers/seo_helper.php Normal file
View File

@ -0,0 +1,275 @@
<?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);
}
}

View File

@ -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',
];

View File

@ -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}',

View File

@ -10,21 +10,8 @@ declare(strict_types=1);
return [
'back_to_home' => 'Retour à laccueil',
'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',
];

View File

@ -22,7 +22,8 @@
"michalsn/codeigniter4-uuid": "dev-develop",
"essence/essence": "^3.5.4",
"codeigniter4/settings": "dev-develop",
"chrisjean/php-ico": "^1.0"
"chrisjean/php-ico": "^1.0",
"melbahja/seo": "^2.0"
},
"require-dev": {
"mikey179/vfsstream": "^v1.6.8",

60
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f35a050323bdc632cd550f9d13f0679c",
"content-hash": "c0a25c3d11c806b4bc62eafb22902bc8",
"packages": [
{
"name": "brick/math",
@ -1120,6 +1120,64 @@
},
"time": "2020-11-02T17:00:53+00:00"
},
{
"name": "melbahja/seo",
"version": "v2.0.0",
"source": {
"type": "git",
"url": "https://github.com/melbahja/seo.git",
"reference": "a42500223cb532d4069e85097cc5b5e6ee402de1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/melbahja/seo/zipball/a42500223cb532d4069e85097cc5b5e6ee402de1",
"reference": "a42500223cb532d4069e85097cc5b5e6ee402de1",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-xml": "*",
"php": ">=7.1"
},
"require-dev": {
"phpunit/phpunit": "^8.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Melbahja\\Seo\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": ["MIT"],
"authors": [
{
"name": "Mohamed ELbahja",
"email": "mohamed@elbahja.me",
"homepage": "https://elbahja.me",
"role": "Developer"
}
],
"description": "Simple PHP library to help developers 🍻 do better on-page SEO optimization",
"keywords": [
"PHP7",
"meta tags",
"open graph",
"php7.1",
"schema.org",
"search engine optimization",
"seo",
"sitemap index",
"sitemap.xml",
"sitemaps",
"twitter tags"
],
"support": {
"issues": "https://github.com/melbahja/seo/issues",
"source": "https://github.com/melbahja/seo/tree/v2.0.0"
},
"time": "2021-10-26T00:36:49+00:00"
},
{
"name": "michalsn/codeigniter4-uuid",
"version": "dev-develop",

View File

@ -4,6 +4,8 @@
<head>
<meta charset="UTF-8"/>
<meta name="robots" content="noindex">
<title><?= $this->renderSection('title') ?> | Castopod Admin</title>
<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>

View File

@ -16,7 +16,7 @@ $podcastNavigation = [
/>
<span class="flex-1 w-full px-2 text-xs font-semibold truncate" title="<?= $podcast->title ?>"><?= $podcast->title ?></span>
</a>
<div class="flex items-center px-4 py-2 border-t border-b border-navigation">
<div class="flex items-center px-4 py-2 border-y border-navigation">
<img
src="<?= $episode->cover->thumbnail_url ?>"
alt="<?= $episode->title ?>"

View File

@ -39,7 +39,7 @@
<div class="px-4 mb-2">
<Forms.Textarea name="message" placeholder="<?= lang('Episode.publish_form.message_placeholder') ?>" autofocus="" rows="2" />
</div>
<div class="flex border-t border-b">
<div class="flex border-y">
<img src="<?= $episode->cover
->thumbnail_url ?>" alt="<?= $episode->title ?>" class="w-24 h-24" />
<div class="flex flex-col flex-1">

View File

@ -41,7 +41,7 @@
<div class="px-4 mb-2">
<Forms.Textarea name="message" placeholder="<?= lang('Episode.publish_form.message_placeholder') ?>" autofocus="" value="<?= $post->message ?>" rows="2" />
</div>
<div class="flex border-t border-b">
<div class="flex border-y">
<img src="<?= $episode->cover
->thumbnail_url ?>" alt="<?= $episode->title ?>" class="w-24 h-24" />
<div class="flex flex-col flex-1">

View File

@ -4,6 +4,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title><?= $episode->title ?></title>
<meta name="description" content="<?= htmlspecialchars(
$episode->description,

View File

@ -12,10 +12,7 @@
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<?= $this->renderSection('meta-tags') ?>
<?php if ($podcast->payment_pointer): ?>
<meta name="monetization" content="<?= $podcast->payment_pointer ?>" />
<?php endif; ?>
<?= $metatags ?>
<?= service('vite')
->asset('styles/index.css', 'css') ?>

View File

@ -1,4 +1,4 @@
<div class="flex items-center border-t border-b border-subtle">
<div class="flex items-center border-y border-subtle">
<div class="relative">
<time class="absolute px-1 text-sm font-semibold text-white rounded bg-black/75 bottom-2 right-2" datetime="PT<?= $episode->audio_file_duration ?>S">
<?= format_duration($episode->audio_file_duration) ?>

View File

@ -1,36 +1,5 @@
<?= $this->extend('episode/_layout') ?>
<?= $this->section('meta-tags') ?>
<title><?= $episode->title ?></title>
<meta name="description" content="<?= htmlspecialchars($episode->description) ?>" />
<link rel="canonical" href="<?= $episode->link ?>" />
<meta property="og:title" content="<?= $episode->title ?>" />
<meta property="og:description" content="<?= $episode->description ?>" />
<meta property="og:locale" content="<?= $podcast->language_code ?>" />
<meta property="og:site_name" content="<?= $podcast->title ?>" />
<meta property="og:url" content="<?= current_url() ?>" />
<meta property="og:image" content="<?= $episode->cover->large_url ?>" />
<meta property="og:image:width" content="<?= config('Images')
->podcastCoverSizes['large'][0] ?>" />
<meta property="og:image:height" content="<?= config('Images')->podcastCoverSizes['large'][1] ?>" />
<meta property="og:description" content="$description" />
<meta property="article:published_time" content="<?= $episode->published_at ?>" />
<meta property="article:modified_time" content="<?= $episode->updated_at ?>" />
<meta property="og:audio" content="<?= $episode->audio_file_opengraph_url ?>" />
<meta property="og:audio:type" content="<?= $episode->audio_file_mimetype ?>" />
<link rel="alternate" type="application/json+oembed" href="<?= base_url(route_to('episode-oembed-json', $podcast->handle, $episode->slug)) ?>" title="<?= $episode->title ?> oEmbed json" />
<link rel="alternate" type="text/xml+oembed" href="<?= base_url(route_to('episode-oembed-xml', $podcast->handle, $episode->slug)) ?>" title="<?= $episode->title ?> oEmbed xml" />
<meta name="twitter:title" content="<?= $episode->title ?>" />
<meta name="twitter:description" content="<?= $episode->description ?>" />
<meta name="twitter:image" content="<?= $episode->cover->large_url ?>" />
<meta name="twitter:card" content="player" />
<meta property="twitter:audio:partner" content="<?= $podcast->publisher ?>" />
<meta property="twitter:audio:artist_name" content="<?= $podcast->owner_name ?>" />
<meta name="twitter:player" content="<?= $episode->getEmbedUrl('light') ?>" />
<meta name="twitter:player:width" content="600" />
<meta name="twitter:player:height" content="200" />
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?php if (can_user_interact()): ?>

View File

@ -1,30 +1,13 @@
<?= $this->extend('episode/_layout') ?>
<?= $this->section('meta-tags') ?>
<title><?= lang('Comment.title', [
'actorDisplayName' => $comment->actor->display_name,
]) ?></title>
<meta name="description" content="<?= $comment->message ?>"/>
<meta property="og:title" content="<?= lang('Comment.title', [
'actorDisplayName' => $comment->actor->display_name,
]) ?>"/>
<meta property="og:locale" content="<?= service(
'request',
)->getLocale() ?>" />
<meta property="og:site_name" content="<?= $comment->actor->display_name ?>" />
<meta property="og:url" content="<?= current_url() ?>" />
<meta property="og:image" content="<?= $comment->actor->avatar_image_url ?>" />
<meta property="og:description" content="<?= $comment->message ?>" />
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<div class="max-w-2xl px-6 mx-auto">
<nav class="mb-2">
<a href="<?= route_to('episode', $podcast->handle, $episode->slug) ?>"
class="inline-flex items-center px-4 py-2 text-sm focus:ring-accent"><?= icon(
'arrow-left',
'mr-2 text-lg',
) . lang('Comment.back_to_comments') ?></a>
'arrow-left',
'mr-2 text-lg',
) . lang('Comment.back_to_comments') ?></a>
</nav>
<div class="pb-12">
<?= $this->include('episode/_partials/comment_with_replies') ?>

View File

@ -1,38 +1,5 @@
<?= $this->extend('episode/_layout') ?>
<?= $this->section('meta-tags') ?>
<title><?= $episode->title ?></title>
<meta name="description" content="<?= htmlspecialchars(
$episode->description,
) ?>" />
<link rel="canonical" href="<?= $episode->link ?>" />
<meta property="og:title" content="<?= $episode->title ?>" />
<meta property="og:description" content="<?= $episode->description ?>" />
<meta property="og:locale" content="<?= $podcast->language_code ?>" />
<meta property="og:site_name" content="<?= $podcast->title ?>" />
<meta property="og:url" content="<?= current_url() ?>" />
<meta property="og:image" content="<?= $episode->cover->large_url ?>" />
<meta property="og:image:width" content="<?= config('Images')
->podcastCoverSizes['large'][0] ?>" />
<meta property="og:image:height" content="<?= config('Images')->podcastCoverSizes['large'][1] ?>" />
<meta property="og:description" content="$description" />
<meta property="article:published_time" content="<?= $episode->published_at ?>" />
<meta property="article:modified_time" content="<?= $episode->updated_at ?>" />
<meta property="og:audio" content="<?= $episode->audio_file_opengraph_url ?>" />
<meta property="og:audio:type" content="<?= $episode->audio_file_mimetype ?>" />
<link rel="alternate" type="application/json+oembed" href="<?= base_url(route_to('episode-oembed-json', $podcast->handle, $episode->slug)) ?>" title="<?= $episode->title ?> oEmbed json" />
<link rel="alternate" type="text/xml+oembed" href="<?= base_url(route_to('episode-oembed-xml', $podcast->handle, $episode->slug)) ?>" title="<?= $episode->title ?> oEmbed xml" />
<meta name="twitter:title" content="<?= $episode->title ?>" />
<meta name="twitter:description" content="<?= $episode->description ?>" />
<meta name="twitter:image" content="<?= $episode->cover->large_url ?>" />
<meta name="twitter:card" content="player" />
<meta property="twitter:audio:partner" content="<?= $podcast->publisher ?>" />
<meta property="twitter:audio:artist_name" content="<?= $podcast->owner_name ?>" />
<meta name="twitter:player" content="<?= $episode->getEmbedUrl('light') ?>" />
<meta name="twitter:player:width" content="600" />
<meta name="twitter:player:height" content="200" />
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?php if (can_user_interact()): ?>

View File

@ -15,12 +15,7 @@
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<meta property="og:title" content="<?= service('settings')
->get('App.siteName') ?>" />
<meta property="og:description" content="<?= service('settings')
->get('App.siteDescription') ?>" />
<meta property="og:site_name" content="<?= service('settings')
->get('App.siteName') ?>" />
<?= $metatags ?>
<?= service('vite')
->asset('styles/index.css', 'css') ?>

View File

@ -1,48 +0,0 @@
<?= helper('page') ?>
<!DOCTYPE html>
<html lang="<?= service('request')
->getLocale() ?>">
<head>
<meta charset="UTF-8"/>
<title><?= $page->title ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<?= service('vite')
->asset('styles/index.css', 'css') ?>
<?= service('vite')
->asset('js/app.ts', 'js') ?>
</head>
<body class="flex flex-col min-h-screen mx-auto bg-base theme-<?= service('settings')
->get('App.theme') ?>">
<?php if (service('authentication')->check()): ?>
<?= $this->include('_admin_navbar') ?>
<?php endif; ?>
<header class="py-8 border-b bg-elevated border-subtle">
<div class="container flex flex-col items-start px-2 py-4 mx-auto">
<a href="<?= route_to('home') ?>"
class="inline-flex items-center mb-2 focus:ring-accent"><?= icon(
'arrow-left',
'mr-2',
) . lang('Page.back_to_home') ?></a>
<Heading tagName="h1" class="text-3xl font-semibold"><?= $page->title ?></Heading>
</div>
</header>
<main class="container flex-1 px-4 py-10 mx-auto">
<div class="prose prose-brand">
<?= $page->content_html ?>
</div>
</main>
<footer class="container flex justify-between px-2 py-4 mx-auto text-sm text-right border-t border-subtle">
<?= render_page_links() ?>
<small><?= lang('Common.powered_by', [
'castopod' =>
'<a class="inline-flex font-semibold hover:underline focus:ring-accent" href="https://castopod.org/" target="_blank" rel="noreferrer noopener">Castopod' . icon('social/castopod', 'ml-1 text-lg') . '</a>',
]) ?></small>
</footer>
</body>

View File

@ -5,21 +5,13 @@
<head>
<meta charset="UTF-8"/>
<title><?= $this->renderSection('title') ?></title>
<meta name="description" content="<?= service('settings')
->get('App.siteDescription') ?>"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<meta property="og:title" content="<?= service('settings')
->get('App.siteName') ?>" />
<meta property="og:description" content="<?= service('settings')
->get('App.siteDescription') ?>" />
<meta property="og:site_name" content="<?= service('settings')
->get('App.siteName') ?>" />
<?= $metatags ?>
<?= service('vite')
->asset('styles/index.css', 'css') ?>
@ -42,9 +34,7 @@
'arrow-left',
'mr-2',
) . lang('Page.back_to_home') ?></a>
<Heading tagName="h1" size="large"><?= isset($page)
? $page->title
: 'Castopod' ?></Heading>
<Heading tagName="h1" size="large"><?= $page->title ?></Heading>
</div>
</header>
<main class="container flex-1 px-4 py-6 mx-auto">

View File

@ -1,4 +1,4 @@
<?= $this->extend('_layout') ?>
<?= $this->extend('pages/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Person.credits') ?>

View File

@ -5,13 +5,17 @@
<head>
<meta charset="UTF-8"/>
<title><?= lang('Page.map') ?></title>
<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/>
<title><?= lang('Page.map.title') . service('settings')->get('App.siteTitleSeparator') . service('settings')->get('App.siteName') ?></title>
<meta name="description" content="<?= lang('Page.map.description', [
'siteName' => service('settings')
->get('App.siteName'),
]) ?>"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<?= service('vite')
->asset('styles/index.css', 'css') ?>
<?= service('vite')
@ -33,7 +37,7 @@
'arrow-left',
'mr-2',
) . lang('Page.back_to_home') ?></a>
<Heading tagName="h1" size="large"><?= lang('Page.map') ?></Heading>
<Heading tagName="h1" size="large"><?= lang('Page.map.title') ?></Heading>
</div>
</header>
<main class="flex-1 w-full h-full">

View File

@ -0,0 +1,11 @@
<?= $this->extend('pages/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Person.credits') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<div class="prose prose-brand">
<?= $page->content_html ?>
</div>
<?= $this->endSection() ?>

View File

@ -12,10 +12,7 @@
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<?= $this->renderSection('meta-tags') ?>
<?php if ($podcast->payment_pointer): ?>
<meta name="monetization" content="<?= $podcast->payment_pointer ?>" />
<?php endif; ?>
<?= $metatags ?>
<?= service('vite')
->asset('styles/index.css', 'css') ?>

View File

@ -1,33 +1,5 @@
<?= $this->extend('podcast/_layout') ?>
<?= $this->section('meta-tags') ?>
<!-- TODO: -->
<link type="application/rss+xml" rel="alternate" title="<?= $podcast->title ?>" href="<?= $podcast->feed_url ?>" />
<title><?= $podcast->title ?></title>
<meta name="description" content="<?= htmlspecialchars(
$podcast->description,
) ?>" />
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<link rel="canonical" href="<?= current_url() ?>" />
<meta property="og:title" content="<?= $podcast->title ?>" />
<meta property="og:description" content="<?= $podcast->description ?>" />
<meta property="og:locale" content="<?= $podcast->language_code ?>" />
<meta property="og:site_name" content="<?= $podcast->title ?>" />
<meta property="og:url" content="<?= current_url() ?>" />
<meta property="og:image" content="<?= $podcast->cover->large_url ?>" />
<meta property="og:image:width" content="<?= config('Images')->podcastCoverSizes['large'][0] ?>" />
<meta property="og:image:height" content="<?= config('Images')->podcastCoverSizes['large'][1] ?>" />
<meta name="twitter:card" content="summary_large_image" />
<?= service('vite')
->asset('styles/index.css', 'css') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<div class="px-2 sm:px-4">
@ -35,14 +7,14 @@
<div class="flex gap-x-4 gap-y-2">
<span class="px-2 py-1 text-sm font-semibold border rounded-sm border-subtle bg-highlight">
<?= lang(
'Podcast.category_options.' . $podcast->category->code,
) ?>
'Podcast.category_options.' . $podcast->category->code,
) ?>
</span>
<?php foreach ($podcast->other_categories as $other_category): ?>
<span class="px-2 py-1 text-sm font-semibold border rounded-sm border-subtle bg-highlight">
<?= lang(
'Podcast.category_options.' . $other_category->code,
) ?>
'Podcast.category_options.' . $other_category->code,
) ?>
</span>
<?php endforeach; ?>
</div>
@ -55,8 +27,8 @@
<?php foreach ($podcast->persons as $person): ?>
<img src="<?= $person->avatar->thumbnail_url ?>" alt="<?= $person->full_name ?>" class="object-cover w-8 h-8 -ml-5 border-2 rounded-full border-background-base last:ml-0" />
<?php $i++; if ($i === 3) {
break;
}?>
break;
}?>
<?php endforeach; ?>
</div>
<?= lang('Podcast.persons', [

View File

@ -1,31 +1,5 @@
<?= $this->extend('podcast/_layout') ?>
<?= $this->section('meta-tags') ?>
<link type="application/rss+xml" rel="alternate" title="<?= $podcast->title ?>" href="<?= $podcast->feed_url ?>"/>
<title><?= $podcast->title ?></title>
<meta name="description" content="<?= htmlspecialchars(
$podcast->description,
) ?>" />
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<link rel="canonical" href="<?= current_url() ?>" />
<meta property="og:title" content="<?= $podcast->title ?>" />
<meta property="og:description" content="<?= $podcast->description ?>" />
<meta property="og:locale" content="<?= $podcast->language_code ?>" />
<meta property="og:site_name" content="<?= $podcast->title ?>" />
<meta property="og:url" content="<?= current_url() ?>" />
<meta property="og:image" content="<?= $podcast->cover->large_url ?>" />
<meta property="og:image:width" content="<?= config('Images')->podcastCoverSizes['large'][0] ?>" />
<meta property="og:image:height" content="<?= config('Images')->podcastCoverSizes['large'][1] ?>" />
<meta name="twitter:card" content="summary_large_image" />
<?= service('vite')
->asset('styles/index.css', 'css') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?php if (can_user_interact()): ?>
@ -53,6 +27,7 @@
<hr class="my-4 border-subtle">
<?php endif; ?>
<div class="flex flex-col gap-y-4">
<?php foreach ($posts as $key => $post): ?>
<?php if ($post->reblog_of_id !== null): ?>

View File

@ -1,31 +1,5 @@
<?= $this->extend('podcast/_layout') ?>
<?= $this->section('meta-tags') ?>
<link type="application/rss+xml" rel="alternate" title="<?= $podcast->title ?>" href="<?= $podcast->feed_url ?>" />
<title><?= $podcast->title ?></title>
<meta name="description" content="<?= htmlspecialchars(
$podcast->description,
) ?>" />
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<link rel="canonical" href="<?= current_url() ?>" />
<meta property="og:title" content="<?= $podcast->title ?>" />
<meta property="og:description" content="<?= $podcast->description ?>" />
<meta property="og:locale" content="<?= $podcast->language_code ?>" />
<meta property="og:site_name" content="<?= $podcast->title ?>" />
<meta property="og:url" content="<?= current_url() ?>" />
<meta property="og:image" content="<?= $podcast->cover->large_url ?>" />
<meta property="og:image:width" content="<?= config('Images')->podcastCoverSizes['large'][0] ?>" />
<meta property="og:image:height" content="<?= config('Images')->podcastCoverSizes['large'][1] ?>" />
<meta name="twitter:card" content="summary_large_image" />
<?= service('vite')
->asset('styles/index.css', 'css') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?php if ($episodes): ?>

View File

@ -12,19 +12,7 @@
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<title><?= lang('Podcast.followTitle', [
'actorDisplayName' => $actor->display_name,
]) ?></title>
<meta name="description" content="<?= $actor->summary ?>"/>
<meta property="og:title" content="<?= lang('Podcast.followTitle', [
'actorDisplayName' => $actor->display_name,
]) ?>"/>
<meta property="og:locale" content="<?= service(
'request',
)->getLocale() ?>" />
<meta property="og:url" content="<?= current_url() ?>" />
<meta property="og:image" content="<?= $actor->avatar_image_url ?>" />
<meta property="og:description" content="<?= $actor->summary ?>" />
<?= $metatags ?>
<?= service('vite')
->asset('styles/index.css', 'css') ?>
@ -32,7 +20,6 @@
->asset('js/podcast.ts', 'js') ?>
</head>
<body class="flex flex-col min-h-screen bg-base theme-<?= service('settings')
->get('App.theme') ?>">
<header class="flex flex-col items-center mb-8">

View File

@ -1,29 +1,12 @@
<?= $this->extend('podcast/_layout') ?>
<?= $this->section('meta-tags') ?>
<title><?= lang('Post.title', [
'actorDisplayName' => $post->actor->display_name,
]) ?></title>
<meta name="description" content="<?= $post->message ?>"/>
<meta property="og:title" content="<?= lang('Post.title', [
'actorDisplayName' => $post->actor->display_name,
]) ?>"/>
<meta property="og:locale" content="<?= service(
'request',
)->getLocale() ?>" />
<meta property="og:site_name" content="<?= $post->actor->display_name ?>" />
<meta property="og:url" content="<?= current_url() ?>" />
<meta property="og:image" content="<?= $post->actor->avatar_image_url ?>" />
<meta property="og:description" content="<?= $post->message ?>" />
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<nav class="py-2">
<a href="<?= route_to('podcast-activity', $podcast->handle) ?>"
class="inline-flex items-center px-4 py-2 text-sm focus:ring-accent"><?= icon(
'arrow-left',
'mr-2 text-lg',
) .
'arrow-left',
'mr-2 text-lg',
) .
lang('Post.back_to_actor_posts', [
'actor' => $post->actor->display_name,
]) ?></a>

View File

@ -10,23 +10,7 @@
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<title><?= lang('Fediverse.' . $action . '.title', [
'actorDisplayName' => $post->actor->display_name,
]) ?></title>
<meta name="description" content="<?= $post->message ?>"/>
<meta property="og:title" content="<?= lang(
'Fediverse.' . $action . '.title',
[
'actorDisplayName' => $post->actor->display_name,
],
) ?>"/>
<meta property="og:locale" content="<?= service(
'request',
)->getLocale() ?>" />
<meta property="og:site_name" content="<?= $post->actor->display_name ?>" />
<meta property="og:url" content="<?= current_url() ?>" />
<meta property="og:image" content="<?= $post->actor->avatar_image_url ?>" />
<meta property="og:description" content="<?= $post->message ?>" />
<?= $metatags ?>
<?= service('vite')
->asset('styles/index.css', 'css') ?>

View File

@ -4,6 +4,8 @@
<head>
<meta charset="UTF-8"/>
<meta name="robots" content="noindex">
<title>Castopod Auth</title>
<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>

View File

@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8"/>
<title>Castopod</title>
<meta name="robots" content="noindex">
<title>Castopod Install</title>
<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" type="image/x-icon" href="<?= service('settings')