PageSourceSearch

https://railsblocks.com/assets/controllers/setup_code_filter_contr…231480539fcf0dfef1500e66cae2063b75d918d3.js

js railsblocks.com collected 2026-09-25 20:24:07 UTC 11,064 bytes, 319 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", "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 (&quot;)
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 (&quot;...&quot;)
293    newCode = newCode.replace(/&quot;((?:(?!&quot;).)*dark:(?:(?!&quot;).)*)&quot;/g, (match, content) => {
294      const filteredClasses = content
295        .split(/\s+/)
296        .filter((cls) => cls && !cls.startsWith("dark:"))
297        .join(" ");
298      return `&quot;${filteredClasses || removedDarkClass}&quot;`;
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(`&quot;${escapedMarker}&quot;`, "g"), "&quot;&quot;");
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.