1const fields = { 2 title: document.getElementById("seo-title"), 3 description: document.getElementById("seo-description"), 4 url: document.getElementById("seo-url") 5}; 6 7const outputs = { 8 title: document.getElementById("snippet-title"), 9 description: document.getElementById("snippet-description"), 10 domain: document.getElementById("snippet-domain"), 11 path: document.getElementById("snippet-path"), 12 score: document.getElementById("quality-score"), 13 summary: document.getElementById("quality-summary"), 14 microcopy: document.getElementById("microcopy"), 15 coachList: document.getElementById("coach-list") 16}; 17 18const counters = { 19 title: document.getElementById("title-count"), 20 description: document.getElementById("description-count"), 21 url: document.getElementById("url-count") 22}; 23 24const statuses = { 25 title: document.getElementById("title-status"), 26 description: document.getElementById("description-status"), 27 url: document.getElementById("url-status") 28}; 29 30const actions = { 31 example: document.getElementById("example-button"), 32 clear: document.getElementById("clear-button"), 33 copyTitle: document.getElementById("copy-title"), 34 copyDescription: document.getElementById("copy-description"), 35 copySnippet: document.getElementById("copy-snippet"), 36 share: document.getElementById("share-button"), 37 desktop: document.getElementById("desktop-view"), 38 mobile: document.getElementById("mobile-view"), 39 stage: document.getElementById("preview-stage") 40}; 41 42const defaults = { 43 title: "Prévisualisez votre title SEO avant publication", 44 description: "Vérifiez la longueur de votre meta description, affinez votre promesse éditoriale et observez un aperçu réaliste du résultat dans Google.", 45 url: "www.exemple.fr/ressources/snippet-seo" 46}; 47 48const exampleData = { 49 title: "Simulateur de title SEO : testez votre snippet Google en direct", 50 description: "Prévisualisez votre title, votre meta description et votre URL pour améliorer la lisibilité, renforcer le CTR et peaufiner votre aperçu référencement.", 51 url: "https://www.mon-site.fr/outils/previsualisateur-title-meta-description" 52}; 53 54// Collez votre lien d'affiliation ici plus tard. 55// Tant que cette chaîne reste vide, aucun bouton n'ouvre de lien externe. 56const referralConfig = { 57 url: "", 58 openInNewTab: true 59}; 60 61function pluralize(count) { 62 return `${count} caractère${count > 1 ? "s" : ""}`; 63} 64 65function triggerReferral(slot) { 66 if (!referralConfig.url) { 67 return; 68 } 69 70 const separator = referralConfig.url.includes("?") ? "&" : "?"; 71 const finalUrl = `${referralConfig.url}${separator}source=${encodeURIComponent(slot)}`; 72 73 if (referralConfig.openInNewTab) { 74 window.open(finalUrl, "_blank", "noopener,noreferrer"); 75 return; 76 } 77 78 window.location.href = finalUrl; 79} 80 81function setMeterState(element, type) { 82 element.className = "meter-pill"; 83 element.classList.add(`meter-pill--${type}`); 84} 85 86function analyzeLength(length, min, max, label) { 87 if (length === 0) { 88 return { 89 tone: "idle", 90 text: `0 caractère`, 91 hint: `${label} vide pour lâinstant.` 92 }; 93 } 94 95 if (length < min) { 96 return { 97 tone: "warn", 98 text: pluralize(length), 99 hint: `${label} encore un peu court.` 100 }; 101 } 102 103 if (length > max) { 104 return { 105 tone: "bad", 106 text: pluralize(length), 107 hint: `${label} potentiellement trop long.` 108 }; 109 } 110 111 return { 112 tone: "good", 113 text: pluralize(length), 114 hint: `${label} dans une zone confortable.` 115 }; 116} 117 118function normalizeUrl(value) { 119 const trimmed = value.trim(); 120 if (!trimmed) { 121 return defaults.url; 122 } 123 124 const prefixed = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; 125 126 try { 127 const parsed = new URL(prefixed); 128 const domain = parsed.hostname.replace(/^www\./i, "") || "exemple.fr"; 129 const rawPath = parsed.pathname.replace(/^\/+|\/+$/g, ""); 130 const path = rawPath ? rawPath.split("/").slice(0, 3).join(" / ") : "page"; 131 132 return { 133 visibleDomain: `www.${domain}`, 134 visiblePath: path, 135 plain: trimmed 136 }; 137 } catch (error) { 138 return { 139 visibleDomain: trimmed.replace(/^https?:\/\//i, "").split("/")[0] || "www.exemple.fr", 140 visiblePath: trimmed.split("/").slice(1, 4).join(" / ") || "page", 141 plain: trimmed 142 }; 143 } 144} 145 146function collectAdvice(title, description, urlLength) { 147 const notes = []; 148 149 if (title.length < 35) { 150 notes.push("Le title semble un peu court : ajoutez un angle ou un bénéfice concret."); 151 } 152 if (title.length > 60) { 153 notes.push("Le title dépasse la zone souvent confortable : raccourcissez sans perdre la promesse."); 154 } 155 if (title && !/[0-9:?|!-]/.test(title) && title.split(" ").length < 6) { 156 notes.push("Le title reste assez générique : précisez le contexte ou le résultat attendu."); 157 } 158 if (description.length < 90) {
159 notes.push("La meta description peut être plus informative avec un bénéfice ou un usage précis."); 160 } 161 if (description.length > 160) { 162 notes.push("La description risque dâêtre tronquée : gardez le message principal au début."); 163 } 164 if (description && !/(gratuit|guide|outil|simuler|prévisualiser|améliorer|optimiser|rapide|facile|simple)/i.test(description)) { 165 notes.push("Ajoutez une promesse plus tangible pour renforcer lâintérêt du clic."); 166 } 167 if (urlLength > 55) { 168 notes.push("LâURL paraît longue : un slug plus court facilite la lecture du snippet."); 169 } 170 171 if (!notes.length) { 172 notes.push("Le title paraît lisible et la promesse reste identifiable dès les premiers mots."); 173 notes.push("La description complète correctement le title sans le paraphraser mot pour mot."); 174 notes.push("Lâensemble forme un snippet cohérent et crédible pour un test rapide."); 175 } 176 177 return notes.slice(0, 3); 178} 179 180function computeScore(title, description, urlLength) { 181 let score = 48; 182 183 if (title.length >= 45 && title.length <= 60) score += 16; 184 else if (title.length > 0) score += 8; 185 186 if (description.length >= 120 && description.length <= 160) score += 16; 187 else if (description.length > 0) score += 8; 188 189 if (urlLength >= 10 && urlLength <= 52) score += 10; 190 else if (urlLength > 0) score += 5; 191 192 if (/(comment|guide|outil|simuler|prévisualiser|optimiser|améliorer|aperçu)/i.test(title)) score += 5; 193 if (/(bénéfice|ctr|lisibilité|google|seo|snippet|résultat|description)/i.test(description)) score += 5; 194 195 return Math.min(score, 100); 196} 197 198function scoreSummary(score) { 199 if (score >= 85) { 200 return "Snippet solide : le message est clair, cohérent et prêt pour une validation éditoriale."; 201 } 202 if (score >= 70) { 203 return "Base convaincante : quelques ajustements peuvent encore renforcer le clic et la précision."; 204 } 205 if (score >= 55) { 206 return "Un point de départ correct. Vous pouvez encore renforcer la clarté et lâintérêt du clic."; 207 } 208 return "Le snippet mérite une reformulation : travaillez surtout la promesse, la précision et la lisibilité."; 209} 210 211function refreshMicrocopy(title, description) { 212 if (!title && !description) { 213 outputs.microcopy.textContent = "Commencez avec un title précis : la prévisualisation sâactualise immédiatement."; 214 return; 215 } 216 217 if (title && !description) { 218 outputs.microcopy.textContent = "Bon début. Ajoutez maintenant une meta description qui complète le title sans le répéter."; 219 return; 220 } 221 222 if (title && description) { 223 outputs.microcopy.textContent = "Votre snippet prend forme. Vérifiez surtout la promesse, le bénéfice et le confort de lecture."; 224 return; 225 } 226 227 outputs.microcopy.textContent = "La description progresse. Un title plus spécifique aidera à cadrer lâaperçu."; 228} 229 230function updatePreview() { 231 const titleValue = fields.title.value.trim(); 232 const descriptionValue = fields.description.value.trim(); 233 const urlValue = fields.url.value.trim(); 234 235 const visibleTitle = titleValue || defaults.title; 236 const visibleDescription = descriptionValue || defaults.description; 237 const normalizedUrl = normalizeUrl(urlValue); 238 239 outputs.title.textContent = visibleTitle; 240 outputs.description.textContent = visibleDescription; 241 outputs.domain.textContent = normalizedUrl.visibleDomain; 242 outputs.path.textContent = normalizedUrl.visiblePath; 243 244 const titleAnalysis = analyzeLength(titleValue.length, 35, 60, "Title"); 245 const descriptionAnalysis = analyzeLength(descriptionValue.length, 90, 160, "Description"); 246 const urlAnalysis = analyzeLength(urlValue.length, 12, 55, "URL"); 247 248 counters.title.textContent = titleAnalysis.text; 249 counters.description.textContent = descriptionAnalysis.text; 250 counters.url.textContent = urlAnalysis.text; 251 252 statuses.title.textContent = titleValue ? titleAnalysis.hint : "Longueur neutre pour commencer."; 253 statuses.description.textContent = descriptionValue ? descriptionAnalysis.hint : "Ajoutez une promesse claire et un angle utile."; 254 statuses.url.textContent = urlValue ? urlAnalysis.hint : "Une URL claire aide à la lecture du résultat."; 255 256 setMeterState(counters.title, titleAnalysis.tone); 257 setMeterState(counters.description, descriptionAnalysis.tone); 258 setMeterState(counters.url, urlAnalysis.tone); 259 260 const advice = collectAdvice(titleValue, descriptionValue, urlValue.length); 261 outputs.coachList.innerHTML = advice.map((item) => `<li>${item}</li>`).join(""); 262 263 const score = computeScore(titleValue, descriptionValue, urlValue.length); 264 outputs.score.textContent = String(score); 265 outputs.summary.textContent = scoreSummary(score); 266 267 refreshMicrocopy(titleValue, descriptionValue); 268} 269 270async function copyText(text, successMessage) { 271 try { 272 await navigator.clipboard.writeText(text); 273 outputs.microcopy.textContent = successMessage; 274 } catch (error) { 275 outputs.microcopy.textContent = "La copie a échoué sur ce navigateur. Sélectionnez le texte manuellement."; 276 } 277} 278 279function buildShareText() { 280 const titleValue = fields.title.value.trim() || defaults.title; 281 const descriptionValue = fields.description.value.trim() || defaults.description;
282 const urlValue = fields.url.value.trim() || defaults.url; 283 284 return `Title : ${titleValue}\nMeta description : ${descriptionValue}\nURL : ${urlValue}`; 285} 286 287actions.example.addEventListener("click", () => { 288 fields.title.value = exampleData.title; 289 fields.description.value = exampleData.description; 290 fields.url.value = exampleData.url; 291 updatePreview(); 292 outputs.microcopy.textContent = "Exemple chargé. Ajustez maintenant la promesse pour lâadapter à votre page."; 293 triggerReferral("example"); 294}); 295 296actions.clear.addEventListener("click", () => { 297 fields.title.value = ""; 298 fields.description.value = ""; 299 fields.url.value = ""; 300 updatePreview(); 301 outputs.microcopy.textContent = "Les champs ont été vidés. Vous pouvez repartir sur une nouvelle idée."; 302 triggerReferral("clear"); 303}); 304 305actions.copyTitle.addEventListener("click", () => { 306 copyText(fields.title.value.trim() || defaults.title, "Title copié dans le presse-papiers."); 307 triggerReferral("copy-title"); 308}); 309 310actions.copyDescription.addEventListener("click", () => { 311 copyText(fields.description.value.trim() || defaults.description, "Meta description copiée dans le presse-papiers."); 312 triggerReferral("copy-description"); 313}); 314 315actions.copySnippet.addEventListener("click", () => { 316 copyText(buildShareText(), "Snippet complet copié dans le presse-papiers."); 317 triggerReferral("copy-snippet"); 318}); 319 320actions.share.addEventListener("click", async () => { 321 const shareText = buildShareText(); 322 const shareData = { 323 title: "Snippet SEO", 324 text: shareText 325 }; 326 327 if (navigator.share) { 328 try { 329 await navigator.share(shareData); 330 outputs.microcopy.textContent = "Partage lancé depuis votre appareil."; 331 triggerReferral("share"); 332 return; 333 } catch (error) { 334 if (error.name === "AbortError") { 335 outputs.microcopy.textContent = "Partage annulé. Votre contenu reste en place."; 336 return; 337 } 338 } 339 } 340 341 copyText(shareText, "Le snippet a été copié pour être partagé."); 342 triggerReferral("share"); 343}); 344 345actions.desktop.addEventListener("click", () => { 346 actions.desktop.classList.add("is-active"); 347 actions.mobile.classList.remove("is-active"); 348 actions.desktop.setAttribute("aria-selected", "true"); 349 actions.mobile.setAttribute("aria-selected", "false"); 350 actions.stage.classList.remove("preview-stage--mobile"); 351 actions.stage.classList.add("preview-stage--desktop"); 352 triggerReferral("desktop-view"); 353}); 354 355actions.mobile.addEventListener("click", () => { 356 actions.mobile.classList.add("is-active"); 357 actions.desktop.classList.remove("is-active"); 358 actions.mobile.setAttribute("aria-selected", "true"); 359 actions.desktop.setAttribute("aria-selected", "false"); 360 actions.stage.classList.remove("preview-stage--desktop"); 361 actions.stage.classList.add("preview-stage--mobile"); 362 triggerReferral("mobile-view"); 363}); 364 365Object.values(fields).forEach((field) => { 366 field.addEventListener("input", updatePreview); 367}); 368 369updatePreview();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.