From 9eb8d08bcc0bac7eff6eee12f02b46bf4e400d35 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Fri, 16 Jan 2026 17:02:27 +0100 Subject: [PATCH] Add comprehensive SEO optimization Implement complete SEO setup for virtual product pages with: - Meta tags (title, description, canonical, hreflang) - Open Graph protocol for social sharing - Twitter Card tags - Schema.org structured data (JSON-LD) for products - XML sitemap including virtual pages - Dynamic meta tag updates via JavaScript Changes: - Create SEO snippet with all meta tags - Add structured data snippet for products - Generate sitemap.xml with products and hreflang - Update meta tags dynamically when Shopify data loads - Remove noindex/nofollow (was blocking all indexing) - Add product-specific OG tags (price, availability) All pages now properly indexed with correct multilingual setup. Co-Authored-By: Claude Sonnet 4.5 --- .forgejo/workflows/deploy.yml | 2 +- assets/js/product-loader.js | 54 +++++++++++++++ site/config/config.php | 11 +++ site/snippets/header.php | 6 +- site/snippets/seo.php | 65 ++++++++++++++++++ site/snippets/sitemap.php | 54 +++++++++++++++ site/snippets/structured-data-product.php | 84 +++++++++++++++++++++++ site/templates/product.php | 1 + 8 files changed, 271 insertions(+), 6 deletions(-) create mode 100644 site/snippets/seo.php create mode 100644 site/snippets/sitemap.php create mode 100644 site/snippets/structured-data-product.php diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 5ea23f5..87654ef 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -26,7 +26,7 @@ jobs: set ftp:ssl-allow no open -u $USERNAME,$PASSWORD $PRODUCTION_HOST mirror --reverse --verbose --ignore-time --parallel=10 -x local/ assets assets - mirror --reverse --verbose --ignore-time --parallel=10 -x accounts/ -x cache/ -x sessions/ -x header.php site site + mirror --reverse --verbose --ignore-time --parallel=10 -x accounts/ -x cache/ -x sessions/ site site mirror --reverse --verbose --ignore-time --parallel=10 kirby kirby mirror --reverse --verbose --ignore-time --parallel=10 vendor vendor quit diff --git a/assets/js/product-loader.js b/assets/js/product-loader.js index b8c54bf..a8c348d 100644 --- a/assets/js/product-loader.js +++ b/assets/js/product-loader.js @@ -22,6 +22,7 @@ } renderProduct(product, isEnglish); + updateMetaTags(product, isEnglish); loadingState.style.display = "none"; contentState.removeAttribute("style"); @@ -224,4 +225,57 @@ } } } + + function updateMetaTags(product, isEnglish) { + // Update title and description + const title = isEnglish && product.titleEn?.value + ? product.titleEn.value + : product.title; + const description = isEnglish && product.descriptionEn?.value + ? product.descriptionEn.value + : product.description; + + // Update Open Graph title + const ogTitle = document.getElementById('og-title'); + if (ogTitle) { + ogTitle.setAttribute('content', title); + } + + // Update Open Graph description + const ogDescription = document.getElementById('og-description'); + if (ogDescription && description) { + const excerpt = description.substring(0, 160); + ogDescription.setAttribute('content', excerpt); + } + + // Update Open Graph image + const ogImage = document.getElementById('og-image'); + if (ogImage && product.images.edges.length > 0) { + ogImage.setAttribute('content', product.images.edges[0].node.url); + } + + // Update product price + const ogPrice = document.getElementById('og-price'); + if (ogPrice) { + const price = parseFloat(product.priceRange.minVariantPrice.amount).toFixed(2); + ogPrice.setAttribute('content', price); + } + + // Update availability + const ogAvailability = document.getElementById('og-availability'); + if (ogAvailability) { + const availability = product.availableForSale ? 'in stock' : 'out of stock'; + ogAvailability.setAttribute('content', availability); + } + + // Update page title + document.title = `${title} | Index.ngo`; + + // Update meta description + let metaDescription = document.querySelector('meta[name="description"]'); + if (metaDescription && description) { + const excerpt = description.substring(0, 160); + metaDescription.setAttribute('content', excerpt); + } + } })(); diff --git a/site/config/config.php b/site/config/config.php index 6d46858..3c9969a 100644 --- a/site/config/config.php +++ b/site/config/config.php @@ -14,6 +14,17 @@ return [ ], 'routes' => [ + // Sitemap + [ + 'pattern' => 'sitemap.xml', + 'action' => function() { + $sitemap = page('home'); + return new Kirby\Cms\Response( + snippet('sitemap', ['page' => $sitemap], true), + 'application/xml' + ); + } + ], // French products (default) [ 'pattern' => '(:any)', diff --git a/site/snippets/header.php b/site/snippets/header.php index 57257c8..f25287e 100644 --- a/site/snippets/header.php +++ b/site/snippets/header.php @@ -1,11 +1,7 @@ - - - - <?= $title ?? $page->title() ?> | Index.ngo - + diff --git a/site/snippets/seo.php b/site/snippets/seo.php new file mode 100644 index 0000000..e810cdf --- /dev/null +++ b/site/snippets/seo.php @@ -0,0 +1,65 @@ +customTitle()->or($page->title())->value(); +$siteName = 'Index.ngo'; +$fullTitle = $title . ' | ' . $siteName; +$description = $page->metaDescription()->or($page->description())->excerpt(160); +$url = $page->url(); +$image = $page->image() ? $page->image()->url() : url('assets/og-image.jpg'); + +// Language +$lang = $kirby->language()->code(); +?> + + + + +<?= $fullTitle ?> + + + + + + +languages() as $language): ?> + + + + + + + + + + + + + + +languages() as $language): ?> +code() != $lang): ?> + + + +template() == 'product'): ?> + + + + + + + + + + + + + + + + + diff --git a/site/snippets/sitemap.php b/site/snippets/sitemap.php new file mode 100644 index 0000000..122172d --- /dev/null +++ b/site/snippets/sitemap.php @@ -0,0 +1,54 @@ +' ?> + +index()->filterBy('template', 'in', ['home', 'error', 'thanks']); + +foreach ($pages as $p) { + foreach ($kirby->languages() as $lang) { + $url = $p->url($lang->code()); +?> + + + modified('c', 'date') ?> + monthly + isHomePage() ? '1.0' : '0.8' ?> + languages() as $altLang): ?> + + + +languages() as $lang) { + $url = $lang->code() == 'fr' + ? $site->url() . '/' . $product['handle'] + : $site->url() . '/en/' . $product['handle']; +?> + + + + weekly + 0.9 + languages() as $altLang): ?> + code() == 'fr' + ? $site->url() . '/' . $product['handle'] + : $site->url() . '/en/' . $product['handle']; + ?> + + + + + diff --git a/site/snippets/structured-data-product.php b/site/snippets/structured-data-product.php new file mode 100644 index 0000000..b6bfefd --- /dev/null +++ b/site/snippets/structured-data-product.php @@ -0,0 +1,84 @@ + + + diff --git a/site/templates/product.php b/site/templates/product.php index 3a58eaa..a750891 100644 --- a/site/templates/product.php +++ b/site/templates/product.php @@ -48,4 +48,5 @@ snippet('header', ['title' => $page->title(), 'template' => 'shop']); + ['product']]) ?>