1import { Controller } from "@hotwired/stimulus"; 2 3const COOKIE_NAME = "rails_blocks_include_dark"; 4const COOKIE_DAYS = 365; 5 6export default class extends Controller { 7 static targets = ["code", "checkbox"]; 8 static values = { 9 includeDark: { type: Boolean, default: true }, 10 }; 11 12 connect() { 13 // Find the main copy button in the parent tabs container - use its text as source of truth 14 // since it comes directly from the server with proper formatting 15 this.copyButton = this.element 16 .closest('[data-controller~="tabs"]') 17 ?.querySelector('[data-controller~="clipboard"][data-clipboard-text]'); 18 19 if (this.copyButton) { 20 this.originalCode = this.copyButton.dataset.clipboardText; 21 } else if (this.hasCodeTarget) { 22 // Fallback to code element content 23 this.originalCode = this.codeTarget.textContent; 24 } 25 26 // Read preference from cookie 27 const savedPreference = this.getCookie(COOKIE_NAME); 28 if (savedPreference !== null) { 29 this.includeDarkValue = savedPreference === "true"; 30 } 31 32 // Set checkbox state and update code 33 if (this.hasCheckboxTarget) { 34 this.checkboxTarget.checked = this.includeDarkValue; 35 } 36 37 // Wait for Prism to be ready before updating code to avoid stripping highlighting 38 this.waitForPrismThenUpdate(); 39 40 // Listen for changes from other instances 41 this.boundSyncHandler = this.syncFromOther.bind(this); 42 document.addEventListener("code-filter:changed", this.boundSyncHandler); 43 44 // Listen for turbo:before-cache to restore original code before caching 45 this.boundBeforeCache = this.beforeCache.bind(this); 46 document.addEventListener("turbo:before-cache", this.boundBeforeCache); 47 } 48 49 disconnect() { 50 document.removeEventListener("code-filter:changed", this.boundSyncHandler); 51 document.removeEventListener("turbo:before-cache", this.boundBeforeCache); 52 53 if (this.boundHighlightComplete) { 54 this.element.removeEventListener("highlight:complete", this.boundHighlightComplete); 55 } 56 if (this.highlightTimeout) { 57 clearTimeout(this.highlightTimeout); 58 } 59 } 60 61 beforeCache() { 62 // Restore original code before Turbo caches the page 63 // This ensures the page is cached with the unmodified code 64 if (this.hasCodeTarget && this.originalCode) { 65 this.codeTarget.textContent = this.originalCode; 66 this.codeTarget.classList.remove("prism-highlighted"); 67 } 68 } 69 70 toggle(event) { 71 this.includeDarkValue = event.target.checked; 72 this.updateCode(); 73 this.saveToCookie(); 74 this.broadcastChange(); 75 } 76 77 syncFromOther(event) { 78 // Don't react to our own events 79 if (event.detail.source === this) return; 80 81 this.includeDarkValue = event.detail.includeDark; 82 83 if (this.hasCheckboxTarget) { 84 this.checkboxTarget.checked = this.includeDarkValue; 85 } 86 87 this.updateCode(); 88 } 89 90 broadcastChange() { 91 document.dispatchEvent( 92 new CustomEvent("code-filter:changed", { 93 detail: { 94 includeDark: this.includeDarkValue, 95 source: this, 96 }, 97 }), 98 ); 99 } 100 101 saveToCookie() { 102 const expires = new Date(); 103 expires.setTime(expires.getTime() + COOKIE_DAYS * 24 * 60 * 60 * 1000); 104 document.cookie = `${COOKIE_NAME}=${this.includeDarkValue};expires=${expires.toUTCString()};path=/;SameSite=Lax`; 105 } 106 107 getCookie(name) { 108 const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)")); 109 return match ? match[2] : null; 110 } 111 112 getRequiredLanguage() { 113 if (!this.hasCodeTarget) return null; 114 115 // Extract language from class like "language-erb" -> "erb" 116 const match = this.codeTarget.className.match(/language-(\w+)/); 117 return match ? match[1] : null; 118 } 119 120 isPrismReady() { 121 if (typeof Prism === "undefined" || !Prism.languages) return false; 122 123 const lang = this.getRequiredLanguage(); 124 if (!lang) return true; // No specific language, Prism core is enough 125 126 // Map language names to what Prism expects 127 const langMap = { 128 html: "markup", 129 xml: "markup", 130 }; 131 const prismLang = langMap[lang] || lang; 132 133 // Check if the language is loaded 134 // For ERB, also check that markup-templating hooks are available 135 if (prismLang === "erb") { 136 return Prism.languages.erb && Prism.languages.ruby && Prism.hooks; 137 } 138 139 return Prism.languages[prismLang] !== undefined; 140 } 141 142 waitForPrismThenUpdate() { 143 // If includeDark is true (default), no code modification needed on initial load 144 // The highlight_controller will handle the initial highlighting 145 if (this.includeDarkValue) { 146 // Just update the copy button text if needed 147 if (this.copyButton && this.originalCode) { 148 this.copyButton.dataset.clipboardText = this.originalCode; 149 } 150 return; 151 } 152 153 // Dark mode classes need to be stripped 154 // Listen for highlight:complete event from highlight_controller 155 this.boundHighlightComplete = this.onHighlightComplete.bind(this); 156 this.element.addEventListener("highlight:complete", this.boundHighlightComplete, { once: true }); 157
158 // Also set a fallback timeout in case the event never fires 159 // (e.g., if the code panel is hidden and highlight_controller doesn't run) 160 this.highlightTimeout = setTimeout(() => { 161 if (this.boundHighlightComplete) { 162 this.element.removeEventListener("highlight:complete", this.boundHighlightComplete); 163 this.boundHighlightComplete = null; 164 } 165 // Check if already highlighted, otherwise wait for Prism and update 166 const isHighlighted = this.hasCodeTarget && this.codeTarget.classList.contains("prism-highlighted"); 167 if (!isHighlighted) { 168 this.waitForPrismOnly(); 169 } 170 }, 3000); 171 } 172 173 onHighlightComplete() { 174 // Clear the fallback timeout 175 if (this.highlightTimeout) { 176 clearTimeout(this.highlightTimeout); 177 this.highlightTimeout = null; 178 } 179 this.boundHighlightComplete = null; 180 181 // Now safe to modify the code 182 this.updateCode(); 183 } 184 185 waitForPrismOnly() { 186 // Just wait for Prism to be ready, then update 187 let attempts = 0; 188 const maxAttempts = 40; // 2 seconds / 50ms 189 190 const checkForPrism = () => { 191 if (this.isPrismReady()) { 192 this.updateCode(); 193 } else if (attempts < maxAttempts) { 194 attempts++; 195 setTimeout(checkForPrism, 50); 196 } else { 197 console.warn("Code filter: Prism.js failed to load"); 198 this.updateCode(); 199 } 200 }; 201 202 checkForPrism(); 203 } 204 205 updateCode() { 206 if (!this.hasCodeTarget || !this.originalCode) return; 207 208 const code = this.includeDarkValue ? this.originalCode : this.removeDarkClasses(this.originalCode); 209 210 // Update displayed code - need to reset the element for re-highlighting 211 this.codeTarget.textContent = code; 212 213 // Remove the prism-highlighted flag so Prism will re-process 214 this.codeTarget.classList.remove("prism-highlighted"); 215 216 // Re-highlight the code if Prism is available and ready 217 if (this.isPrismReady()) { 218 try { 219 Prism.highlightElement(this.codeTarget); 220 this.codeTarget.classList.add("prism-highlighted"); 221 } catch (e) { 222 console.warn("Code filter: Prism highlighting failed:", e); 223 } 224 } 225 226 // Update the main copy button's clipboard text 227 if (this.copyButton) { 228 this.copyButton.dataset.clipboardText = code; 229 } 230 } 231 232 removeDarkClasses(code) { 233 // 1. Handle HTML class attributes: class="..." 234 let newCode = code.replace(/class="([^"]*)"/g, (match, classContent) => { 235 // Split classes, filter out dark: ones, rejoin 236 const filteredClasses = classContent 237 .split(/\s+/) 238 .filter((cls) => cls && !cls.startsWith("dark:")) 239 .join(" "); 240 return `class="${filteredClasses}"`; 241 }); 242 243 // 2. Handle Tailwind @apply directives: @apply ...; 244 newCode = newCode.replace(/(@apply\s+)([^;]+)(;)/g, (match, prefix, content, suffix) => { 245 let filtered = content 246 // Remove dark: classes that have preceding whitespace (handles middle/end) 247 .replace(/\s+dark:[^\s]+/g, "") 248 // Remove dark: classes at the start of the list 249 .replace(/^\s*dark:[^\s]+\s*/g, "") 250 // Clean up any remaining multiple spaces (keep newlines if any) 251 .replace(/[ \t]{2,}/g, " ") 252 // Trim trailing whitespace before the semicolon 253 .trimEnd(); 254 255 return `${prefix}${filtered}${suffix}`; 256 }); 257 258 // 3. Handle .dark CSS selector blocks: .dark { ... } or .dark selector { ... } 259 // Remove entire .dark { nested rules } blocks (with nested braces) 260 newCode = newCode.replace(/^\.dark\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}\s*\n?/gm, ""); 261 262 // 4. Handle .dark prefixed selectors: .dark selector { ... } 263 // e.g., ".dark input[type="search"] { ... }" 264 newCode = newCode.replace(/^\.dark\s+[^{]+\{[^}]*\}\s*\n?/gm, ""); 265 266 // 5. Handle JavaScript classList.add/remove/toggle calls with dark: classes 267 // e.g., classList.add("class1", "dark:class2") -> classList.add("class1") 268 newCode = newCode.replace(/\.(classList)\.(add|remove|toggle)\(([^)]+)\)/g, (match, classList, method, args) => { 269 // Filter out quoted strings that start with "dark:" or 'dark:' 270 const filteredArgs = args 271 .split(",") 272 .map((arg) => arg.trim()) 273 .filter((arg) => { 274 // Check if this argument is a dark: class (in quotes) 275 const isDarkClass = /^["']dark:/.test(arg); 276 return !isDarkClass; 277 }) 278 .join(", "); 279 280 // If all args were dark: classes, return empty method call 281 return `.${classList}.${method}(${filteredArgs})`; 282 }); 283 284 // 6. Handle quoted strings containing Tailwind class lists (Ruby, JS, etc.) 285 // e.g., "bg-green-50 dark:bg-green-400/10" -> "bg-green-50" 286 // Handles both regular quotes and HTML-encoded quotes (") 287 // Regular quotes first 288 newCode = newCode.replace(/(["'])([^"']*dark:[^"']*)\1/g, (match, quote, content) => { 289 const filteredClasses = content 290 .split(/\s+/) 291 .filter((cls) => cls && !cls.startsWith("dark:")) 292 .join(" "); 293 return `${quote}${filteredClasses}${quote}`; 294 }); 295 // HTML-encoded quotes ("...") 296 newCode = newCode.replace(/"((?:(?!").)*dark:(?:(?!").)*)"/g, (match, content) => { 297 const filteredClasses = content 298 .split(/\s+/) 299 .filter((cls) => cls && !cls.startsWith("dark:")) 300 .join(" "); 301 return `"${filteredClasses}"`; 302 }); 303 304 // 7. Remove lines containing "dark mode" comments (case insensitive) 305 // e.g., "/* Add separate dark mode version */" or "// dark mode styling" 306 newCode = newCode.replace(/^.*dark\s*mode.*$\n?/gim, ""); 307 308 // 8. Clean up any resulting multiple blank lines 309 newCode = newCode.replace(/\n{3,}/g, "\n\n"); 310 311 return newCode; 312 } 313};
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.