Add comprehensive SEO optimization
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s

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 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-01-16 17:02:27 +01:00
parent f69d990349
commit 9eb8d08bcc
8 changed files with 271 additions and 6 deletions

View file

@ -26,7 +26,7 @@ jobs:
set ftp:ssl-allow no set ftp:ssl-allow no
open -u $USERNAME,$PASSWORD $PRODUCTION_HOST 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 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 kirby kirby
mirror --reverse --verbose --ignore-time --parallel=10 vendor vendor mirror --reverse --verbose --ignore-time --parallel=10 vendor vendor
quit quit

View file

@ -22,6 +22,7 @@
} }
renderProduct(product, isEnglish); renderProduct(product, isEnglish);
updateMetaTags(product, isEnglish);
loadingState.style.display = "none"; loadingState.style.display = "none";
contentState.removeAttribute("style"); 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);
}
}
})(); })();

View file

@ -14,6 +14,17 @@ return [
], ],
'routes' => [ '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) // French products (default)
[ [
'pattern' => '(:any)', 'pattern' => '(:any)',

View file

@ -1,11 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<?= $kirby->language()->code() ?>"> <html lang="<?= $kirby->language()->code() ?>">
<head> <head>
<meta charset="UTF-8"> <?php snippet('seo') ?>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title><?= $title ?? $page->title() ?> | Index.ngo</title>
<link rel="icon" type="image/png" href="<?= url('assets/favicon.png') ?>"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.css" />
<script src="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.js"></script>

65
site/snippets/seo.php Normal file
View file

@ -0,0 +1,65 @@
<?php
/**
* SEO meta tags
*/
// Basic meta
$title = $page->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();
?>
<!-- Basic Meta Tags -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $fullTitle ?></title>
<?php if ($description): ?>
<meta name="description" content="<?= $description ?>">
<?php endif ?>
<!-- Canonical & Alternate Languages -->
<link rel="canonical" href="<?= $url ?>">
<?php foreach($kirby->languages() as $language): ?>
<link rel="alternate" hreflang="<?= $language->code() ?>" href="<?= $page->url($language->code()) ?>">
<?php endforeach ?>
<link rel="alternate" hreflang="x-default" href="<?= $page->url('fr') ?>">
<!-- Open Graph -->
<meta property="og:type" content="<?= $page->template() == 'product' ? 'product' : 'website' ?>">
<meta property="og:site_name" content="<?= $siteName ?>">
<meta property="og:title" content="<?= $title ?>" id="og-title">
<meta property="og:description" content="<?= $description ?>" id="og-description">
<meta property="og:url" content="<?= $url ?>">
<meta property="og:image" content="<?= $image ?>" id="og-image">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:locale" content="<?= $lang == 'fr' ? 'fr_FR' : 'en_US' ?>">
<?php foreach($kirby->languages() as $language): ?>
<?php if ($language->code() != $lang): ?>
<meta property="og:locale:alternate" content="<?= $language->code() == 'fr' ? 'fr_FR' : 'en_US' ?>">
<?php endif ?>
<?php endforeach ?>
<?php if ($page->template() == 'product'): ?>
<!-- Product-specific OG tags (will be updated by JS) -->
<meta property="product:price:amount" content="0" id="og-price">
<meta property="product:price:currency" content="EUR">
<meta property="product:availability" content="in stock" id="og-availability">
<meta property="product:brand" content="Index.ngo">
<?php endif ?>
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<?= $title ?>">
<?php if ($description): ?>
<meta name="twitter:description" content="<?= $description ?>">
<?php endif ?>
<meta name="twitter:image" content="<?= $image ?>">
<!-- Favicon -->
<link rel="icon" type="image/png" href="<?= url('assets/favicon.png') ?>">

54
site/snippets/sitemap.php Normal file
View file

@ -0,0 +1,54 @@
<?= '<?xml version="1.0" encoding="UTF-8"?>' ?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<?php
$site = site();
$kirby = kirby();
// Static pages
$pages = $site->index()->filterBy('template', 'in', ['home', 'error', 'thanks']);
foreach ($pages as $p) {
foreach ($kirby->languages() as $lang) {
$url = $p->url($lang->code());
?>
<url>
<loc><?= $url ?></loc>
<lastmod><?= $p->modified('c', 'date') ?></lastmod>
<changefreq>monthly</changefreq>
<priority><?= $p->isHomePage() ? '1.0' : '0.8' ?></priority>
<?php foreach ($kirby->languages() as $altLang): ?>
<xhtml:link rel="alternate" hreflang="<?= $altLang->code() ?>" href="<?= $p->url($altLang->code()) ?>" />
<?php endforeach ?>
</url>
<?php
}
}
// Virtual product pages from Shopify
$products = getShopifyProducts();
foreach ($products as $product) {
foreach ($kirby->languages() as $lang) {
$url = $lang->code() == 'fr'
? $site->url() . '/' . $product['handle']
: $site->url() . '/en/' . $product['handle'];
?>
<url>
<loc><?= $url ?></loc>
<lastmod><?= date('c') ?></lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
<?php foreach ($kirby->languages() as $altLang): ?>
<?php
$altUrl = $altLang->code() == 'fr'
? $site->url() . '/' . $product['handle']
: $site->url() . '/en/' . $product['handle'];
?>
<xhtml:link rel="alternate" hreflang="<?= $altLang->code() ?>" href="<?= $altUrl ?>" />
<?php endforeach ?>
</url>
<?php
}
}
?>
</urlset>

View file

@ -0,0 +1,84 @@
<?php
/**
* Structured Data (JSON-LD) for Product pages
* This will be populated by JavaScript after Shopify data is loaded
*/
?>
<script type="application/ld+json" id="product-schema">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": "<?= $page->title() ?>",
"description": "",
"image": [],
"offers": {
"@type": "Offer",
"url": "<?= $page->url() ?>",
"priceCurrency": "EUR",
"price": "0",
"availability": "https://schema.org/InStock",
"seller": {
"@type": "Organization",
"name": "Index.ngo"
}
},
"brand": {
"@type": "Organization",
"name": "Index.ngo"
}
}
</script>
<script>
// Update structured data when product loads
(function() {
const container = document.querySelector('[data-product-loader]');
if (!container) return;
const handle = container.dataset.shopifyHandle;
const language = container.dataset.language || 'fr';
const isEnglish = language === 'en';
const cart = new ShopifyCart({
domain: 'nv7cqv-bu.myshopify.com',
storefrontAccessToken: 'dec3d35a2554384d149c72927d1cfd1b'
});
cart.getProductByHandle(handle).then(product => {
if (!product) return;
const title = isEnglish && product.titleEn?.value ? product.titleEn.value : product.title;
const description = isEnglish && product.descriptionEn?.value ? product.descriptionEn.value : product.description;
const price = parseFloat(product.priceRange.minVariantPrice.amount);
const images = product.images.edges.map(edge => edge.node.url);
const availability = product.availableForSale ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock';
const schema = {
"@context": "https://schema.org/",
"@type": "Product",
"name": title,
"description": description,
"image": images,
"offers": {
"@type": "Offer",
"url": window.location.href,
"priceCurrency": "EUR",
"price": price.toFixed(2),
"availability": availability,
"seller": {
"@type": "Organization",
"name": "Index.ngo"
}
},
"brand": {
"@type": "Organization",
"name": "Index.ngo"
}
};
const schemaScript = document.getElementById('product-schema');
if (schemaScript) {
schemaScript.textContent = JSON.stringify(schema, null, 2);
}
});
})();
</script>

View file

@ -48,4 +48,5 @@ snippet('header', ['title' => $page->title(), 'template' => 'shop']);
</section> </section>
</main> </main>
<?php snippet('structured-data-product') ?>
<?php snippet('footer', ['scripts' => ['product']]) ?> <?php snippet('footer', ['scripts' => ['product']]) ?>