import { createRoot } from "react-dom/client"; import App from "./App.tsx"; import "./index.css"; import { isPrerenderCrawler } from "./lib/isPrerenderCrawler"; // Bot visibility override. // framer-motion's `whileInView` only animates when a section scrolls into // view. Prerender (kalstein.plus:3002) runs headless Chrome WITHOUT // scrolling, so any motion.* with `initial={{ opacity: 0 }}` + `whileInView` // stays stuck at opacity:0 in the captured HTML. Even though the text is in // the DOM, Encited and similar audit tools that parse rendered HTML // penalise this as low visibility. // For bots only, inject a global CSS override that forces full opacity and // neutral transforms on every element framer-motion has set inline styles // on. Real users see animations exactly as before. if (typeof window !== "undefined" && isPrerenderCrawler()) { document.documentElement.setAttribute("data-bot", "true"); const style = document.createElement("style"); style.id = "prerender-visibility-override"; style.textContent = ` html[data-bot="true"] [style*="opacity"] { opacity: 1 !important; } html[data-bot="true"] [style*="transform"] { transform: none !important; } `; document.head.appendChild(style); } // Workaround for React "removeChild" error caused by external DOM modifications // (browser extensions, auto-translate, preview overlays). This is a well-known // React issue: https://github.com/facebook/react/issues/11538 if (typeof Node !== "undefined") { const originalRemoveChild = Node.prototype.removeChild; // @ts-ignore — intentional monkey-patch Node.prototype.removeChild = function (child: T): T { if (child.parentNode !== this) { return child; } return originalRemoveChild.call(this, child) as T; }; const originalInsertBefore = Node.prototype.insertBefore; // @ts-ignore — intentional monkey-patch Node.prototype.insertBefore = function (newNode: T, refNode: Node | null): T { if (refNode && refNode.parentNode !== this) { return newNode; } return originalInsertBefore.call(this, newNode, refNode) as T; }; } createRoot(document.getElementById("root")!).render(); // ============================================================ // Trailing-slash normalizer (SEO safety net) // ------------------------------------------------------------ // Backend/sitemap serve URLs WITHOUT trailing slash. Ensure: // 1. tags never carry a trailing slash on internal paths // 2. history.pushState / replaceState strip trailing slashes so React // Router navigations don't leave "/foo/" in the address bar. // Excludes: root "/", language-only prefixes (/es/, /en/, etc.) and // paths that look like assets. // ============================================================ if (typeof window !== "undefined") { const LANG_ONLY = /^\/(es|en|fr|it|nl|de|pt|pl|ar|se|sv)\/?$/i; const ASSET_RE = /\.[a-z0-9]{2,5}$/i; const stripTrailingSlash = (path: string): string => { if (!path || path === "/") return path; if (LANG_ONLY.test(path)) return path; if (ASSET_RE.test(path)) return path; if (path.length > 1 && path.endsWith("/")) return path.replace(/\/+$/, ""); return path; }; const normalizeUrlString = (url: string): string => { try { // Relative path (no protocol) if (/^\/[^/]/.test(url) || url === "/") { const [p, q = ""] = url.split("?"); const [path, hash = ""] = p.split("#"); const cleaned = stripTrailingSlash(path); return cleaned + (q ? `?${q}` : "") + (hash ? `#${hash}` : (p.includes("#") ? "#" : "")); } const u = new URL(url, window.location.origin); if (u.origin !== window.location.origin) return url; // external untouched u.pathname = stripTrailingSlash(u.pathname); return u.pathname + u.search + u.hash; } catch { return url; } }; // Patch history methods so React Router updates the bar without trailing slash. const origPush = history.pushState; const origReplace = history.replaceState; history.pushState = function (data: any, unused: string, url?: string | URL | null) { if (typeof url === "string") url = normalizeUrlString(url); return origPush.call(this, data, unused, url as any); }; history.replaceState = function (data: any, unused: string, url?: string | URL | null) { if (typeof url === "string") url = normalizeUrlString(url); return origReplace.call(this, data, unused, url as any); }; // Clean current URL on initial load. const cleanCurrent = stripTrailingSlash(window.location.pathname); if (cleanCurrent !== window.location.pathname) { history.replaceState(history.state, "", cleanCurrent + window.location.search + window.location.hash); } // MutationObserver: scrub trailing slashes from internal links. const cleanAnchor = (a: HTMLAnchorElement) => { const raw = a.getAttribute("href"); if (!raw) return; if (/^(mailto:|tel:|javascript:|#)/i.test(raw)) return; const cleaned = normalizeUrlString(raw); if (cleaned !== raw) a.setAttribute("href", cleaned); }; const scan = (root: ParentNode) => { root.querySelectorAll?.("a[href]").forEach((el) => cleanAnchor(el as HTMLAnchorElement)); }; const obs = new MutationObserver((mutations) => { for (const m of mutations) { if (m.type === "attributes" && m.target instanceof HTMLAnchorElement) { cleanAnchor(m.target); } m.addedNodes.forEach((n) => { if (n instanceof HTMLAnchorElement) cleanAnchor(n); else if (n instanceof Element) scan(n); }); } }); const startObs = () => { scan(document); obs.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["href"] }); }; if (document.body) startObs(); else document.addEventListener("DOMContentLoaded", startObs); } // Prerender.io readiness signaling — content-aware gating so bots receive // the FULL HTML (h2, microdata, product description) instead of just the // page shell with a slug-derived . // // History v2: the previous gate fired at 5s when document.title changed // from the default. But document.title is updated immediately from the // URL slug BEFORE the WooCommerce/Blog data finishes loading via the // Supabase Edge Function bridge. Result: Prerender captured a snapshot // with title + h1 derived from the slug but ZERO content (no h2, no // itemprop, no YR code, no real description). Google indexed empty pages. // // New strategy: identify routes that depend on async content (product + // blog detail pages) and require RICH content signals before marking // ready. For static routes (home, listings) keep the fast 5s path. const DEFAULT_TITLE = "Kalstein+ | Plataforma Integral de Equipamiento Científico e Industrial"; const markReady = () => { if (!(window as any).prerenderReady) { (window as any).prerenderReady = true; } }; const LANG_PREFIXES = new Set(["es", "en", "fr", "it", "nl", "de", "pt", "pl", "ar", "se"]); const STATIC_SINGLE_SEGMENTS = new Set([ "", "usuarios", "users", "utilisateurs", "utenti", "gebruikers", "benutzer", "utilizadores", "uzytkownicy", "anvandare", "distribuidores", "distributors", "distributeurs", "distributori", "distributoren", "dystrybutorzy", "distributorer", "fabricantes", "manufacturers", "fabricants", "produttori", "fabrikanten", "hersteller", "producenci", "tillverkare", "recomendacion-de-modelo-pc", "model-recommendation-pc", "recommandation-de-modele-pc", "raccomandazione-modello-pc", "model-aanbeveling-pc", "modellempfehlung-pc", "recomendacao-de-modelo-pc", "rekomendacja-modelu-pc", "modellrekommendation-pc", "productos", "products", "produits", "prodotti", "partner", "producten", "produkte", "produtos", "produkty", "produkter", "cotizacion", "quote", "devis", "preventivo", "offerte", "angebot", "orcamento", "wycena", "offert", "contacto", "contact", "contatto", "contatti", "kontakt", "blog", "blogg", "empresa", "company", "entreprise", "azienda", "bedrijf", "unternehmen", "firma", "foretag", "videos", "video", "wideo", "videor", "faq", "terminos-y-condiciones", "terms-and-conditions", "conditions-generales", "termini-e-condizioni", "algemene-voorwaarden", "allgemeine-geschaeftsbedingungen", "termos-e-condicoes", "regulamin", "villkor", "politica-de-privacidad", "privacy-policy", "politique-de-confidentialite", "informativa-sulla-privacy", "privacybeleid", "datenschutzrichtlinie", "politica-de-privacidade", "polityka-prywatnosci", "integritetspolicy", ]); // Pages that fetch their content via async API → need stricter gating. const isAsyncContentRoute = (): boolean => { const path = window.location.pathname.toLowerCase(); const segments = path.split("/").filter(Boolean); const contentSegments = LANG_PREFIXES.has(segments[0]) ? segments.slice(1) : segments; // Product detail: /producto/, /product/, /produit/, /prodotto/ // Blog detail: /blog/<slug>, /noticia/<slug>, /article/<slug> // Heuristic: any route with a slug ≥ 3 segments OR known prefixes. if (/\/(producto|product|produit|prodotto|productos)\/[^/]+/.test(path)) return true; if (/\/(blog|article|noticia|noticias|articulo|articolo)\/[^/]+/.test(path)) return true; // Category landing pages (10 localized prefixes, all served by CategoryProducts.tsx) if (/\/(categoria-producto|category-product|categorie-produit|categoria-prodotto|productcategorie|produktkategorie|categoria-de-produto|categoria-produto|kategoria-produktu|produktkategori)\//.test(path)) return true; // Bare slugs that look like product slugs (contain "yr" + digits) if (/yr[\-_]?\d{3,}/i.test(path)) return true; // Root catch-all blog/barbacoa articles: /my-article-slug and /es/my-article-slug if (contentSegments.length === 1 && !STATIC_SINGLE_SEGMENTS.has(contentSegments[0])) return true; return false; }; // Has the page rendered REAL content (not just shell)? const hasRealContent = (): boolean => { // 1) No loading shell visible if (document.querySelector('[data-loading-shell="true"]')) return false; // 2) Explicit page marker emitted only by fully-rendered detail views. // For barbacoa the sentinel is mounted AFTER all sections render, so its // presence is a strong signal the full content tree is in the DOM. const barbacoaSentinel = document.querySelector('[data-prerender-ready="barbacoa"]') as HTMLElement | null; const hasRenderedDetailMarker = !!document.querySelector('[data-prerender-ready="article"], [data-prerender-ready="product"], [data-prerender-ready="category"]') || !!barbacoaSentinel; // 2b) Barbacoa-specific extra gate: ensure the sections container has at // least as many direct children as declared by the template, and that // visible text is substantial (intro + sections + FAQ). if (barbacoaSentinel) { const declared = parseInt(barbacoaSentinel.getAttribute('data-barbacoa-sections-count') || '0', 10); const sectionsHost = document.querySelector('[data-prerender-content="barbacoa-sections"]'); const renderedSections = sectionsHost ? sectionsHost.children.length : 0; if (declared > 0 && renderedSections < declared) return false; const visibleSections = document.querySelectorAll('[data-prerender-content="barbacoa-sections"] [data-prerender-section="true"]').length; if (declared > 0 && visibleSections < declared) return false; const lastVisibleSection = document.querySelector('[data-prerender-content="barbacoa-sections"] [data-prerender-section="true"]:last-of-type'); if (!lastVisibleSection) return false; const visibleText = (document.querySelector('main, #root')?.textContent || '').replace(/\s+/g, ' ').trim(); if (visibleText.length < 1500) return false; } // 3) JSON-LD/microdata must be paired with visible body depth; metadata alone // was the exact failure mode where Google received only the capture shell. const lds = document.querySelectorAll('script[type="application/ld+json"]'); let hasSpecificStructuredData = !!document.querySelector('[itemtype*="schema.org/Product"], [itemtype*="schema.org/Article"]'); for (const ld of Array.from(lds)) { const txt = ld.textContent || ""; if (/"@type"\s*:\s*"(Product|Article|NewsArticle|BreadcrumbList)"/.test(txt)) { hasSpecificStructuredData = true; break; } } // 4) Require real visible article/product content, not just header + spinner. const contentNodes = Array.from(document.querySelectorAll('[data-prerender-content]')); const rootText = (contentNodes.length > 0 ? contentNodes.map((node) => node.textContent || "").join(" ") : (document.querySelector('main, article, #root')?.textContent || "")) .replace(/\s+/g, " ") .trim(); const h1Count = document.querySelectorAll('h1').length; const h2Count = document.querySelectorAll('main h2, article h2, [data-prerender-content] h2, #root h2').length; const pCount = document.querySelectorAll('main p, article p, [data-prerender-content] p, #root p').length; // Thresholds lowered so short but valid articles/products still flip ready // instead of being indexed as the loading shell. if (hasRenderedDetailMarker && h1Count >= 1 && rootText.length >= 300 && (h2Count >= 1 || pCount >= 2)) return true; if (hasSpecificStructuredData && h1Count >= 1 && rootText.length >= 500 && (h2Count >= 1 || pCount >= 2)) return true; if (h1Count >= 1 && h2Count >= 1 && pCount >= 3 && rootText.length >= 700) return true; return false; }; const checkAndMark = () => { const root = document.getElementById("root"); if (!root) return false; if (document.querySelector('[data-loading-shell="true"]')) return false; if (isAsyncContentRoute()) { // Detail views (blog/product/barbacoa) have page-local readiness gates // that verify the actual body text. Do not let this generic fallback // flip `prerenderReady` from early metadata or a partially mounted tree. if (document.querySelector('[data-prerender-ready="article"], [data-prerender-ready="product"], [data-prerender-ready="barbacoa"], [data-prerender-ready="category"]')) { return false; } // Strict gating: require real product/article content if (hasRealContent()) { markReady(); return true; } return false; } // Static route → SAME strict contract as detail views. // Require BOTH: // a) page-level marker `data-prerender-ready="static"` emitted by // useStaticPagePrerender only after <main> has real text + headings + footer. // b) substantive body (≥2 headings, ≥800 chars, footer present). // We deliberately removed the old `titleChanged` and `rootHasDepth` // fast-paths: HomeSEO sets document.title via useEffect BEFORE the rest // of the React tree mounts, which let Prerender capture an empty shell // with the correct <title> (Googlebot indexed it as low-content). const staticMarker = document.querySelector('[data-prerender-ready="static"]'); if (!staticMarker) return false; const mainEl = document.querySelector('main') || root; const bodyText = (mainEl.textContent || '').replace(/\s+/g, ' ').trim(); const headings = mainEl.querySelectorAll('h1, h2, h3').length; const hasFooter = !!document.querySelector('footer'); if (hasFooter && headings >= 2 && bodyText.length >= 800) { markReady(); return true; } return false; }; // Poll every 500ms — fires as soon as content arrives. // Extended to ~85s so slow async detail pages (WooCommerce + barbacoa // templates) can finish hydrating before Prerender snapshots the DOM. // Prerender on kalstein.plus:3002 has a generous page-load timeout that // accommodates this window. let pollAttempts = 0; const pollInterval = setInterval(() => { pollAttempts++; if (checkAndMark() || pollAttempts > 170) { clearInterval(pollInterval); } }, 500); // Hard fallback. For async detail routes we still attempt a final mark so // crawlers don't get stuck on prerenderReady=false; for static routes we // flip ready unconditionally (the static crawler middleware already served // a content-rich HTML to bots, so this only matters for human-facing SPA). setTimeout(() => { clearInterval(pollInterval); // Unified hard fallback (detail + static): if a loading shell is still // visible OR we never reached the strict content gate, leave // prerenderReady=false so the page-level <meta name="prerender-status-code" // content="503"> tells Prerender to NOT cache the shell. Google will retry. if (document.querySelector('[data-loading-shell="true"]')) return; if (!checkAndMark()) { // Last-resort: only mark ready if there's substantive visible content. // Otherwise leave the 503 in place rather than caching an empty snapshot. const root = document.getElementById("root"); const txt = (root?.textContent || "").replace(/\s+/g, " ").trim(); const headings = document.querySelectorAll('h1, h2, h3').length; if (txt.length >= 800 && headings >= 2) markReady(); } }, 85000);