Shopware 6 Snippet: ALT- & TITLE-Tags automatisch generieren (PDP Galerie)
Setzt auf Produktseiten automatisch sinnvolle ALT- und TITLE-Attribute für Produktbilder (inkl. Varianten wie Farbe/Nikotin/Ohm), ohne vorhandene, gute Texte zu überschreiben. Zusätzlich optimiert es das erste Hauptbild für bessere LCP-Werte.
Was macht das Snippet?
Dieses Snippet läuft ausschließlich auf der Produktdetailseite und arbeitet gezielt innerhalb der Produktgalerie. Es erzeugt für Produktbilder automatisch beschreibende Texte nach dem Muster:
- ALT: Produktname – Variante – Ansicht x/y (Ansicht nur bei Hauptbildern)
- TITLE: Produktname – Variante (ohne Ansicht-Zähler)
Welche Varianten berücksichtigt es?
- Farbe (z. B. „Schwarz“)
- Nikotinstärke (z. B. „10 mg/ml“)
- Widerstand (z. B. „0.8 Ohm“)
Wichtig: Das Snippet ersetzt ALT/TITLE nur dann, wenn das Attribut leer oder generisch ist (z. B. „image“, „placeholder“, „Produktbild“). Bereits gepflegte, sinnvolle ALT/TITLE-Texte bleiben erhalten.
Performance (LCP) inklusive
Das erste Hauptbild erhält automatisch loading="eager" und fetchpriority="high", damit es als LCP-Kandidat bevorzugt geladen wird.
Alle übrigen Bilder bekommen – falls nicht vorhanden – ein sinnvolles Lazy-Loading-Default.
Warum ist das wichtig?
- Barrierefreiheit (A11y): Screenreader brauchen sinnvolle ALT-Texte, um Inhalte zu beschreiben.
- SEO/Images: Bildsignale werden sauberer (insb. bei Variantenbildern), ohne „Keyword-Spam“.
- Saubere Datenbasis: Konsistente Benennung hilft auch intern (z. B. bei Exports, Feeds, Audits).
- Performance: LCP-Bild bevorzugen, ohne manuell in Templates einzugreifen.
Hinweis: ALT/TITLE sind keine Garantie für Rankings – aber sie sind Teil einer sauberen technischen Basis. Dieses Snippet löst den häufigsten Praxisfall: fehlende oder generische Werte.
Einbau (Shopware 6)
Füge das Skript als Custom-JavaScript ein – idealerweise am Ende der Seite (Footer), damit die Galerie-Elemente bereits im DOM vorhanden sind. Geeignete Orte sind z. B. ein Theme-/Plugin-Customizing-Feld für Footer-JS oder ein eigenes kleines Customizing-Plugin.
Minimaler Funktionstest
- Produktseite öffnen
- Rechtsklick auf ein Galerie-Bild → „Untersuchen“
- Prüfen, ob
altundtitlesinnvoll gesetzt sind - Varianten wechseln (Farbe/Nikotin/Ohm) → Werte sollten in den Texten auftauchen
Code
Hinweis: Standardmäßig ist INCLUDE_BRAND deaktiviert (bewusst). Die Ansicht-Nummerierung wird nur für Hauptbilder gesetzt.
<script>
(function(){
'use strict';
// ---- CONFIG ----
const INCLUDE_BRAND = false; // Hersteller im Standard deaktiviert
const NUMBER_VIEWS = true; // Ansicht x/y nur bei Hauptbildern
const DEBUG = false;
// ---- helpers ----
const $ = (s, r=document)=>r.querySelector(s);
const $$ = (s, r=document)=>Array.from(r.querySelectorAll(s));
const cleanWS = s => (s||'').replace(/\s+/g,' ').trim();
const log = (...a)=> DEBUG && console.log('[ALTFIX]', ...a);
// Entfernt ( ... ) und [ ... ] und extra Spaces
function stripBrackets(s){
s = cleanWS(s);
if (!s) return '';
s = s.replace(/\([^)]*\)/g, ' ');
s = s.replace(/\[[^\]]*\]/g, ' ');
return cleanWS(s);
}
const isGeneric = s => {
s = cleanWS(s);
return !s || /^(bild|image|produktbild|product image|thumbnail|platzhalter|placeholder|no image|picture)$/i.test(s);
};
function getProductName(){
const h = cleanWS($('h1.product-detail-name, .product-detail-name, h1')?.textContent);
const fallback = cleanWS(document.title.split('|')[0]);
return stripBrackets(h || fallback);
}
function getBrand(){
const t = cleanWS($('.product-detail-manufacturer a, .manufacturer a')?.textContent)
|| cleanWS($('.product-detail-manufacturer img[alt]')?.getAttribute('alt'));
return stripBrackets(t);
}
// ---- option normalization (incl. bracket stripping) ----
function normalizeOption(t){
t = stripBrackets(t);
if (!t) return '';
t = t.replace(/^(farbe|color|colour|couleur|colore|kleur)\s*[:\-]\s*/i,'');
t = t.split(/[\u2013\u2014–—-]|•/)[0].trim();
if (/(nicht\s*verfügbar|zur\s*zeit.*nicht|out\s*of\s*stock|unavailab|ausverkauft|currently\s*unavailable)/i.test(t)) return '';
if (t.length > 50) return '';
return t;
}
// ---- extract from configurator groups by label ----
function findGroupValueByLabel(labelRegex){
const labels = Array.from(document.querySelectorAll(
'label.product-detail-configurator-group-title, .product-detail-configurator-group-title, .sw-product-variant__label, .product-variant-group-label, .product-variant-group__label'
)).filter(l => labelRegex.test(l.textContent||''));
for (const lab of labels){
// SELECT via for-id
const forId = lab.getAttribute('for');
if (forId){
const target = document.getElementById(forId);
if (target && target.tagName === 'SELECT'){
const opt = target.options[target.selectedIndex];
const txt = normalizeOption(opt?.text);
if (txt) return txt;
}
}
// radio/chips
const group = lab.closest('.product-detail-configurator-group, .sw-product-variants, .product-variants') || lab.parentElement;
const container =
group?.querySelector('.product-detail-configurator-options, .sw-product-variants__options, .product-variant-group-options') ||
lab.nextElementSibling || group;
const checked = container?.querySelector?.('input[type="radio"]:checked');
if (checked){
const forLbl = document.querySelector(`label[for="${checked.id}"]`);
const txt = normalizeOption(forLbl?.getAttribute('title') || forLbl?.textContent || checked.value);
if (txt) return txt;
}
const active =
container?.querySelector?.('.is-active, .is-selected, .selected, .is--active, [aria-pressed="true"], [aria-checked="true"], [aria-selected="true"]');
if (active){
const raw = active.getAttribute('aria-label') || active.getAttribute('title') || active.getAttribute('data-original-title') || active.textContent;
const txt = normalizeOption(raw);
if (txt) return txt;
}
const chip = container?.querySelector?.('[title],[aria-label],[data-original-title]');
if (chip){
const txt = normalizeOption(chip.getAttribute('aria-label') || chip.getAttribute('title') || chip.getAttribute('data-original-title'));
if (txt) return txt;
}
}
return '';
}
// ---- FALLBACK: read from properties/specs (dt/dd, th/td) ----
function findSpecValueByLabel(labelRegex){
const attrBlocks = document.querySelectorAll('.product-detail-properties, .product-specs, dl, table');
for (const blk of attrBlocks){
const lab = Array.from(blk.querySelectorAll('dt, th, .label, .name'))
.find(n => labelRegex.test(n.textContent||''));
if (lab){
const raw = cleanWS(lab.nextElementSibling?.textContent || lab.parentElement?.querySelector('dd, td, .value')?.textContent);
const txt = normalizeOption(raw);
if (txt) return txt;
}
}
return '';
}
// ---- FARBE ----
function getColor(){
return findGroupValueByLabel(/(farbe|color|colour)/i)
|| findSpecValueByLabel(/(farbe|color|colour)/i)
|| '';
}
// ---- NIKOTINSTÄRKE ----
function normalizeNicotine(s){
s = normalizeOption(s);
if (!s) return '';
const m = s.match(/(\d+(?:[.,]\d+)?)\s*(mg\/?ml|mg)\b/i);
if (m){
const val = m[1].replace(',', '.');
const unit = m[2].toLowerCase().includes('ml') ? 'mg/ml' : 'mg';
return `${val} ${unit}`;
}
const n = s.match(/(\d+(?:[.,]\d+)?)\b/);
if (n && /(nikotin|nic|salt|salz)/i.test(s)){
return `${n[1].replace(',', '.') } mg/ml`;
}
return '';
}
function getNicotine(){
const v1 = findGroupValueByLabel(/(nikotin|nicotine|nikotinstärke|stärke|strength)/i);
const n1 = normalizeNicotine(v1);
if (n1) return n1;
const v2 = findSpecValueByLabel(/(nikotin|nicotine|nikotinstärke|stärke|strength)/i);
const n2 = normalizeNicotine(v2);
if (n2) return n2;
return '';
}
// ---- WIDERSTAND / OHM ----
function normalizeResistance(s){
s = normalizeOption(s);
if (!s) return '';
const m = s.match(/(\d+(?:[.,]\d+)?)\s*(?:ohm|Ω)\b/i);
if (m){
return `${m[1].replace(',', '.') } Ohm`;
}
const n = s.match(/(\d+(?:[.,]\d+)?)/);
if (n && /(widerstand|resistance)/i.test(s)){
return `${n[1].replace(',', '.') } Ohm`;
}
return '';
}
function getResistance(){
const v1 = findGroupValueByLabel(/(ohm|Ω|widerstand|resistance)/i);
const r1 = normalizeResistance(v1);
if (r1) return r1;
const v2 = findSpecValueByLabel(/(ohm|Ω|widerstand|resistance)/i);
const r2 = normalizeResistance(v2);
if (r2) return r2;
return '';
}
// ---- image collection / main views ----
function normalizeSrc(u){
if (!u) return '';
try{
const url = new URL(u, location.origin);
let p = url.pathname;
p = p.replace(/\/thumbnail\/[^/]+\/[^/]+\/?/g, '/');
p = p.replace(/-\d+x\d+(?=\.[a-z]+$)/i, '');
return p.toLowerCase();
}catch(e){
return (''+u).toLowerCase();
}
}
function isThumbOrDecorative(img){
const c = (sel)=> img.closest(sel);
if (c('.gallery-slider-thumbnails, .product-detail-thumbnails, .sw-image-slider__thumbnails, .thumbnails, .thumbnail, .is-nav, .navigation')) return true;
if (/\bthumb/i.test(img.className)) return true;
const w = img.naturalWidth || img.width || 0;
const h = img.naturalHeight || img.height || 0;
if (w && h && (w < 64 || h < 64)) return true;
return false;
}
function collectMainImages(wrap){
const all = $$('img', wrap).filter(img => img.getAttribute('src') || img.getAttribute('data-src'));
const uniq = new Map();
for (const img of all){
if (isThumbOrDecorative(img)) continue;
const key = normalizeSrc(img.getAttribute('data-zoom-image') || img.getAttribute('data-src') || img.getAttribute('src'));
if (!key) continue;
if (!uniq.has(key)) uniq.set(key, img);
}
return Array.from(uniq.values());
}
// ---- build ALT / TITLE ----
function buildAlt(base, brand, color, nicotine, resistance, idx, total){
const parts = [];
if (base) parts.push(base);
if (INCLUDE_BRAND && brand && base && !base.toLowerCase().includes(brand.toLowerCase())) {
parts.push(brand);
}
// Reihenfolge: Farbe -> Widerstand -> Nikotin
if (color) parts.push(color);
if (resistance) parts.push(resistance);
if (nicotine) parts.push(nicotine);
if (NUMBER_VIEWS && idx != null) parts.push(`Ansicht ${idx}/${total}`);
return parts.join(' – ');
}
function buildTitle(base, color, nicotine, resistance){
const parts = [];
if (base) parts.push(base);
if (color) parts.push(color);
if (resistance) parts.push(resistance);
if (nicotine) parts.push(nicotine);
return parts.join(' – ');
}
// Nur Galerie innerhalb PDP anfassen (kein Fallback auf document)
function applyToGallery(){
const name = getProductName();
if (!name) return;
// Galerie-Wrapper (PDP)
const wrap = document.querySelector(
'.product-detail-media .gallery-slider, .product-detail-media, .product-detail-gallery'
);
if (!wrap) return;
const brand = getBrand();
const color = getColor();
const nicotine = getNicotine();
const resistance = getResistance();
const mainImgs = collectMainImages(wrap);
if (!mainImgs.length) return;
const total = mainImgs.length;
// Map main image index by normalized src
const indexBySrc = new Map();
mainImgs.forEach((img, i) => {
const key = normalizeSrc(img.getAttribute('data-zoom-image') || img.getAttribute('data-src') || img.getAttribute('src'));
if (key) indexBySrc.set(key, i+1);
});
// Nur IMG innerhalb wrap
const allImgs = $$('img', wrap).filter(img => img.getAttribute('src') || img.getAttribute('data-src'));
allImgs.forEach(img => {
if (isThumbOrDecorative(img)) return;
const key = normalizeSrc(img.getAttribute('data-zoom-image') || img.getAttribute('data-src') || img.getAttribute('src'));
const isMain = key && indexBySrc.has(key);
const idx = isMain ? indexBySrc.get(key) : null;
const currentAlt = img.getAttribute('alt');
const currentTitle = img.getAttribute('title');
const alt = buildAlt(name, brand, color, nicotine, resistance, idx, total);
const title = buildTitle(name, color, nicotine, resistance);
// nur ersetzen, wenn generisch/leer
if (isGeneric(currentAlt)) img.setAttribute('alt', alt);
if (!currentTitle || isGeneric(currentTitle)) img.setAttribute('title', title);
// LCP Best Practice: erstes Hauptbild nicht lazy
if (isMain && idx === 1){
img.setAttribute('loading', 'eager');
img.setAttribute('fetchpriority', 'high');
} else {
if (!img.hasAttribute('loading')) img.setAttribute('loading', 'lazy');
}
if (!img.hasAttribute('decoding')) img.setAttribute('decoding', 'async');
});
// og:image:alt ohne Ansicht-Zähler
const ogText = buildAlt(name, brand, color, nicotine, resistance, null, 1);
let ogAlt = document.querySelector('meta[property="og:image:alt"]');
if (!ogAlt) {
ogAlt = document.createElement('meta');
ogAlt.setAttribute('property','og:image:alt');
document.head.appendChild(ogAlt);
}
ogAlt.setAttribute('content', ogText);
}
// initial
document.addEventListener('DOMContentLoaded', applyToGallery);
// observers (nur innerhalb der Produktgalerie)
document.addEventListener('DOMContentLoaded', () => {
const media = document.querySelector(
'.product-detail-media .gallery-slider, .product-detail-media, .product-detail-gallery'
);
if (!media) return;
new MutationObserver(muts => {
if (muts.some(m => [...m.addedNodes].some(n => n.tagName==='IMG' || n.querySelector?.('img')))) {
applyToGallery();
}
}).observe(media, { childList:true, subtree:true });
const varWrap = document.querySelector('.product-detail-configurator, .sw-product-variants, .product-variants');
if (varWrap){
new MutationObserver(() => applyToGallery()).observe(varWrap, { attributes:true, childList:true, subtree:true });
varWrap.addEventListener('change', applyToGallery, { passive:true });
varWrap.addEventListener('click', applyToGallery, { passive:true });
}
});
})();
</script>
Anpassen (wenn dein Theme abweicht)
1) Galerie-Wrapper
Das Snippet sucht die Galerie in:
.product-detail-media .gallery-slider, .product-detail-media oder .product-detail-gallery.
Wenn dein Theme andere Container nutzt, ergänze sie in der querySelector()-Liste in applyToGallery().
2) Varianten-Sektion
Varianten werden in .product-detail-configurator, .sw-product-variants oder .product-variants beobachtet.
Falls du ein eigenes Variantensystem hast, ergänze dort den Wrapper.
3) Reihenfolge & Inhalt
- ALT setzt standardmäßig: Name – Farbe – Ohm – Nikotin – Ansicht x/y
- TITLE setzt: Name – Farbe – Ohm – Nikotin
- Brand ist bewusst aus (
INCLUDE_BRAND=false) - Ansichten sind an/aus über
NUMBER_VIEWS
Tipp: Wenn du sehr viele Variantenattribute hast (z. B. „Zugart“, „Leistung“, „Watt“), ist es meist besser, nur 1–3 wirklich relevante Varianten in ALT/TITLE zu nehmen – sonst werden Texte lang und unruhig.
Checkliste (vor Live-Gang)
- Ein Produkt mit mehreren Bildern öffnen (PDP).
- In DevTools prüfen: bekommt ein Hauptbild einen sinnvollen
altundtitle? - Varianten wechseln (Farbe/Nikotinstärke/Ohm) → ALT/TITLE sollten mitziehen.
- Prüfen, ob das erste Hauptbild
fetchpriority="high"hat (LCP). - Thumbnails sollten nicht verändert werden (nur Hauptbilder).
- Kein „Keyword-Stuffing“: Texte sollten kurz & natürlich bleiben.
Hinweis zu Support (Free Snippet)
Dieses Snippet wird kostenlos bereitgestellt. Es gibt daher keinen individuellen Einbau- oder Theme-Support. Hintergrund: Shopware-6-Themes unterscheiden sich teils stark in DOM-Struktur und Klassen.
Wenn du Unterstützung brauchst (Selektor-Anpassung, Testing, Integration in dein Theme/Plugin, Updatesicherheit), kannst du optional einen kostenpflichtigen Einbau-Check buchen: Fixpreis ab 79 € (abhängig vom Aufwand). Alternativ: Sammel mehrere Anpassungen, dann lohnt sich ein Paket.
Transparenz: Die Snippets sind praxiserprobt, aber nicht „Plug-and-Play garantiert“ für jedes Theme. Genau dafür ist der Einbau-Check da.
Herkunft & Praxisbezug
Entwickelt aus realen Anforderungen in produktiven Shopware-6-Shops (Variantendarstellung, Galerie-Handling, Bild-SEO und LCP). Das Snippet ist bewusst defensiv geschrieben: Es arbeitet scoped innerhalb der PDP-Galerie und überschreibt keine gepflegten ALT/TITLE-Texte.
Ziel: eine saubere technische Basis für bessere Barrierefreiheit und konsistente Bildmetadaten – ohne Core-Anpassungen.