feat: add og:video support and Pinterest rich pin tags
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s

- Add og:video meta tags with per-article video URL field and auto
  MIME type detection for YouTube/Vimeo/direct files. Includes DB
  migration for og_video column. (closes #59)
- Add Pinterest rich pin tags: article:tag from Joomla content tags
  on article pages, product:availability from MokoSuiteShop stock
  quantity on product pages. (closes #60)
This commit is contained in:
Jonathan Miller
2026-06-23 10:25:21 -05:00
parent 881bb0a2ae
commit 5b29690d34
9 changed files with 88 additions and 2 deletions
+2
View File
@@ -18,6 +18,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Fediverse/Mastodon `fediverse:creator` meta tag — first extension on any CMS to support this (#57)
- Live character count indicators on OG title, OG description, SEO title, meta description fields with color-coded warnings (#58)
- LinkedIn social preview card in article/menu editor alongside Facebook and Twitter/X previews (#61)
- `og:video` meta tag support with per-article video URL field, auto-detect MIME type for YouTube/Vimeo/direct files (#59)
- Pinterest rich pin tags: `article:tag` from Joomla content tags, `product:availability` from MokoSuiteShop stock (#60)
- Site-wide default OG title and description plugin parameters
- Discord embed color via `theme-color` meta tag (color picker in plugin config)
- LinkedIn article tags: `article:published_time`, `article:modified_time`, `article:author`
+8
View File
@@ -60,6 +60,14 @@
<option value="product">Product</option>
<option value="profile">Profile</option>
</field>
<field
name="og_video"
type="url"
label="PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO"
description="PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC"
filter="url"
validate="url"
/>
<field
name="published"
type="list"
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` (
`og_description` TEXT NOT NULL,
`og_image` VARCHAR(512) NOT NULL DEFAULT '',
`og_type` VARCHAR(50) NOT NULL DEFAULT 'article',
`og_video` VARCHAR(512) NOT NULL DEFAULT '',
`seo_title` VARCHAR(70) NOT NULL DEFAULT '',
`meta_description` VARCHAR(200) NOT NULL DEFAULT '',
`robots` VARCHAR(100) NOT NULL DEFAULT '',
@@ -0,0 +1,5 @@
--
-- MokoJoomOpenGraph 01.03.00 - Add og_video column
--
ALTER TABLE `#__mokoog_tags` ADD COLUMN `og_video` VARCHAR(512) NOT NULL DEFAULT '' AFTER `og_type`;
@@ -49,6 +49,14 @@
<option value="music.song">Music</option>
<option value="video.other">Video</option>
</field>
<field
name="og_video"
type="url"
label="PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO"
description="PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC"
filter="url"
validate="url"
/>
</fieldset>
<fieldset name="mokoog_seo" label="PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL"
description="PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC">
@@ -14,6 +14,9 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recomme
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO="Video URL"
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video to embed in social sharing previews. Supports direct video URLs and YouTube/Vimeo links. Outputs og:video meta tags."
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
@@ -14,6 +14,9 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recomme
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO="Video URL"
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video to embed in social sharing previews. Supports direct video URLs and YouTube/Vimeo links. Outputs og:video meta tags."
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
@@ -194,7 +194,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'og_title', 'og_description', 'og_image', 'og_type',
'og_title', 'og_description', 'og_image', 'og_type', 'og_video',
'seo_title', 'meta_description', 'robots', 'canonical_url',
]))
->from($db->quoteName('#__mokoog_tags'))
@@ -249,6 +249,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
'og_description' => trim($ogData['og_description'] ?? ''),
'og_image' => trim($ogData['og_image'] ?? ''),
'og_type' => trim($ogData['og_type'] ?? 'article'),
'og_video' => trim($ogData['og_video'] ?? ''),
'seo_title' => trim($ogData['seo_title'] ?? ''),
'meta_description' => trim($ogData['meta_description'] ?? ''),
'robots' => trim($robots),
@@ -92,7 +92,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
if ($catOg) {
// Merge: category fills any gaps in the content-level data
foreach (['og_title', 'og_description', 'og_image', 'og_type', 'seo_title', 'meta_description', 'robots', 'canonical_url'] as $field) {
foreach (['og_title', 'og_description', 'og_image', 'og_type', 'og_video', 'seo_title', 'meta_description', 'robots', 'canonical_url'] as $field) {
if (empty($ogData->$field) && !empty($catOg->$field)) {
$ogData->$field = $catOg->$field;
}
@@ -184,6 +184,27 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
$doc->setMetaData('fediverse:creator', $fediverseCreator);
}
// og:video tags
$videoUrl = $ogData->og_video ?? '';
if ($videoUrl) {
$doc->setMetaData('og:video', $videoUrl, 'property');
$doc->setMetaData('og:video:secure_url', $videoUrl, 'property');
// Detect video type from URL
if (str_contains($videoUrl, 'youtube.com') || str_contains($videoUrl, 'youtu.be')
|| str_contains($videoUrl, 'vimeo.com')) {
$doc->setMetaData('og:video:type', 'text/html', 'property');
} else {
$ext = strtolower(pathinfo(parse_url($videoUrl, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION));
$mimeMap = ['mp4' => 'video/mp4', 'webm' => 'video/webm', 'ogg' => 'video/ogg'];
$doc->setMetaData('og:video:type', $mimeMap[$ext] ?? 'video/mp4', 'property');
}
$doc->setMetaData('og:video:width', '1280', 'property');
$doc->setMetaData('og:video:height', '720', 'property');
}
// LinkedIn article tags
if ($option === 'com_content' && $view === 'article' && $id > 0) {
$doc->setMetaData('article:published_time', $this->getArticleDate($id, 'publish_up'), 'property');
@@ -206,6 +227,39 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
}
}
// Pinterest rich pin tags
if ($option === 'com_content' && $view === 'article' && $id > 0) {
$article = $this->loadArticle($id);
if ($article) {
// Extract Joomla content tags for article:tag (used by Pinterest article pins)
$db = Factory::getDbo();
$tagQuery = $db->getQuery(true)
->select($db->quoteName('t.title'))
->from($db->quoteName('#__tags', 't'))
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm')
. ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'))
->where($db->quoteName('m.content_item_id') . ' = ' . $id)
->where($db->quoteName('t.published') . ' = 1');
$db->setQuery($tagQuery);
$tags = $db->loadColumn();
foreach ($tags as $tag) {
$doc->setMetaData('article:tag', $tag, 'property');
}
}
}
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
$productData = $this->loadShopProduct($id);
if ($productData) {
$availability = ((int) ($productData->stock_qty ?? 0) > 0) ? 'instock' : 'outofstock';
$doc->setMetaData('product:availability', $availability, 'property');
}
}
// Fire event so third-party plugins can add custom OG/social tags
$eventData = [
'subject' => $doc,
@@ -306,6 +360,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
'og_description' => '',
'og_image' => '',
'og_type' => '',
'og_video' => '',
'seo_title' => '',
'meta_description' => '',
'robots' => '',