PageSourceSearch

https://railsblocks.com/assets/controllers/code_filter_controller-…198b7c2c104e7b0765cef49eda95d4503f8220da.js

js railsblocks.com collected 2026-09-25 20:22:57 UTC 10,873 bytes, 313 lines download raw bytes

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 (&quot;)
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 (&quot;...&quot;)
296    newCode = newCode.replace(/&quot;((?:(?!&quot;).)*dark:(?:(?!&quot;).)*)&quot;/g, (match, content) => {
297      const filteredClasses = content
298        .split(/\s+/)
299        .filter((cls) => cls && !cls.startsWith("dark:"))
300        .join(" ");
301      return `&quot;${filteredClasses}&quot;`;
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.