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