@ape-egg/vibe 1.3.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,25 +82,25 @@ impl HtmlParser {
82
82
  &self.cache
83
83
  }
84
84
 
85
- /// Process HTML content: transform custom tags to <component>, optionally inline, apply accessibility
85
+ /// Process HTML content: transform custom tags to <component>, optionally inline, transform custom elements
86
86
  pub fn process_html(
87
87
  &self,
88
88
  content: &str,
89
- accessibility: bool,
90
- exclude_tags: &[String],
91
89
  elements_as_is: bool,
90
+ reserved_elements: &[String],
91
+ components_as_is: bool,
92
92
  components_dir: &str,
93
93
  ) -> String {
94
- self.process_html_with_cache(content, accessibility, exclude_tags, elements_as_is, components_dir, &HashMap::new())
94
+ self.process_html_with_cache(content, elements_as_is, reserved_elements, components_as_is, components_dir, &HashMap::new())
95
95
  }
96
96
 
97
97
  pub fn process_html_with_cache(
98
98
  &self,
99
99
  content: &str,
100
- accessibility: bool,
101
- exclude_tags: &[String],
102
100
  elements_as_is: bool,
103
- components_dir: &str,
101
+ reserved_elements: &[String],
102
+ components_as_is: bool,
103
+ _components_dir: &str,
104
104
  external_cache: &HashMap<String, String>,
105
105
  ) -> String {
106
106
  // Extract and preserve DOCTYPE declaration if present
@@ -109,17 +109,22 @@ impl HtmlParser {
109
109
 
110
110
  let mut result = content.to_string();
111
111
 
112
- // Step 1: Transform custom tags matching element files to <component> tags
113
- result = self.transform_custom_tags_to_component(&result, components_dir);
112
+ // Step 1: Inline custom elements directly (ALWAYS, even with components_as_is)
113
+ // Custom elements like <card> are always inlined because runtime doesn't know about /components directory
114
+ result = self.inline_custom_elements(&result);
114
115
 
115
- // Step 2: If elements_as_is is false, recursively inline all <component> elements
116
- if !elements_as_is {
116
+ // Step 2: Handle explicit <component src="..."> elements
117
+ // If components_as_is is false, recursively inline all <component> elements
118
+ if !components_as_is {
117
119
  result = self.inline_component_elements(&result, external_cache);
120
+
121
+ // NOTE: Don't run inline_custom_elements again here - it causes infinite recursion
122
+ // Custom elements inside components are already processed when the component was cached
118
123
  }
119
124
 
120
- // Step 3: Accessibility transform if requested
121
- if accessibility {
122
- result = transform_custom_tags_to_divs(&result, exclude_tags);
125
+ // Step 3: Transform custom elements to divs if elements_as_is is false (accessible by default)
126
+ if !elements_as_is {
127
+ result = transform_custom_tags_to_divs(&result, reserved_elements);
123
128
  }
124
129
 
125
130
  // Step 4: Restore DOCTYPE if it was present
@@ -133,134 +138,237 @@ impl HtmlParser {
133
138
  result
134
139
  }
135
140
 
136
- /// Transform custom tags that match element files to <component src="..."> tags
137
- fn transform_custom_tags_to_component(&self, content: &str, components_dir: &str) -> String {
138
- let mut result = content.to_string();
139
-
140
- // Find all tags that match loaded elements
141
- for tag_name in self.cache.keys() {
142
- // Match opening and closing tags with any attributes and children
143
- let tag_pattern = format!(r"<{}(\s[^>]*)?>", regex::escape(tag_name));
144
- let tag_re = regex::Regex::new(&tag_pattern).unwrap();
145
- let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
146
-
147
- // Find all occurrences and transform them
148
- let mut matches: Vec<(usize, usize, String, Option<String>)> = Vec::new();
149
-
150
- // Find opening tags
151
- for cap in tag_re.find_iter(&result) {
152
- let start = cap.start();
153
- let tag_with_attrs = cap.as_str();
154
-
155
- // Extract attributes (everything between tag name and >)
156
- let attrs = if tag_with_attrs.ends_with('>') {
157
- let inner = &tag_with_attrs[tag_name.len() + 1..tag_with_attrs.len() - 1];
158
- if inner.trim().is_empty() {
159
- None
160
- } else {
161
- Some(inner.to_string())
162
- }
163
- } else {
164
- None
165
- };
166
-
167
- // Find corresponding closing tag
168
- if let Some(closing_pos) = result[cap.end()..].find(&closing_pattern) {
169
- let closing_start = cap.end() + closing_pos;
170
- let closing_end = closing_start + closing_pattern.len();
171
- let children = result[cap.end()..closing_start].to_string();
172
-
173
- matches.push((start, closing_end, children, attrs));
174
- }
175
- }
176
-
177
- // Replace from end to start to maintain indices
178
- matches.reverse();
179
- for (start, end, children, attrs) in matches {
180
- let attrs_str = attrs.map(|a| format!(" {}", a)).unwrap_or_default();
181
- let replacement = format!("<component src=\"/{}/{}.html\"{}>{}</component>", components_dir, tag_name, attrs_str, children);
182
- result.replace_range(start..end, &replacement);
183
- }
141
+ /// Inline custom elements directly with their HTML content (ALWAYS, even with components_as_is)
142
+ /// Custom elements like <card> are always inlined because runtime doesn't know about /components directory
143
+ fn inline_custom_elements(&self, content: &str) -> String {
144
+ if self.cache.is_empty() {
145
+ return content.to_string(); // No custom elements to inline
184
146
  }
185
147
 
186
- result
187
- }
188
-
189
- /// Recursively inline all <component> elements with their HTML content
190
- fn inline_component_elements(&self, content: &str, external_cache: &HashMap<String, String>) -> String {
191
148
  let mut result = content.to_string();
192
149
  let mut changed = true;
193
150
  let mut iterations = 0;
194
- const MAX_ITERATIONS: usize = 100; // Prevent infinite loops
151
+ const MAX_ITERATIONS: usize = 10; // Reasonable limit for deeply nested custom elements
195
152
 
196
153
  while changed && iterations < MAX_ITERATIONS {
197
154
  changed = false;
198
155
  iterations += 1;
199
156
 
200
- // Match <component src="..." attrs...>children</component>
201
- // Handles internal (/components/file.html) and external (http://... or https://...)
202
- // Use (?s) flag to make . match newlines
203
- let component_re = regex::Regex::new(
204
- r#"(?s)<component\s+src="([^"]+)"([^>]*)>(.*?)</component>"#
205
- ).unwrap();
157
+ // Find all tags that match loaded elements
158
+ for (tag_name, element) in &self.cache {
159
+ // Match opening and closing tags with any attributes and children
160
+ let tag_pattern = format!(r"<{}(\s[^>]*)?>", regex::escape(tag_name));
161
+ let tag_re = regex::Regex::new(&tag_pattern).unwrap();
162
+ let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
163
+
164
+ // Find all occurrences
165
+ let mut matches: Vec<(usize, usize, String, HashMap<String, String>)> = Vec::new();
166
+
167
+ // Find opening tags
168
+ for cap in tag_re.find_iter(&result) {
169
+ let start = cap.start();
170
+ let tag_with_attrs = cap.as_str();
171
+
172
+ // Extract attributes and parse as props
173
+ let attrs_str = if tag_with_attrs.ends_with('>') {
174
+ let inner = &tag_with_attrs[tag_name.len() + 1..tag_with_attrs.len() - 1];
175
+ inner.to_string()
176
+ } else {
177
+ String::new()
178
+ };
179
+ let props = Self::parse_props(&attrs_str);
206
180
 
207
- let matches: Vec<_> = component_re.captures_iter(&result).map(|cap| {
208
- let full_match = cap.get(0).unwrap();
209
- let src = cap.get(1).unwrap().as_str();
210
- let attrs_str = cap.get(2).map(|m| m.as_str().to_string()).unwrap_or_default();
211
- let slot_content = cap.get(3).map(|m| m.as_str().to_string());
181
+ // Find corresponding closing tag
182
+ if let Some(closing_pos) = result[cap.end()..].find(&closing_pattern) {
183
+ let closing_start = cap.end() + closing_pos;
184
+ let closing_end = closing_start + closing_pattern.len();
185
+ let slot_content = result[cap.end()..closing_start].to_string();
212
186
 
213
- // Parse attributes into a map (prop_name -> prop_value)
214
- let props = Self::parse_props(&attrs_str);
187
+ matches.push((start, closing_end, slot_content, props));
188
+ }
189
+ }
215
190
 
216
- (full_match.start(), full_match.end(), src.to_string(), props, slot_content)
217
- }).collect();
191
+ if !matches.is_empty() {
192
+ changed = true;
193
+ }
218
194
 
219
- if !matches.is_empty() {
220
- changed = true;
221
- }
195
+ // Replace from end to start to maintain indices
196
+ matches.reverse();
197
+ for (start, end, slot_content, props) in matches {
198
+ let mut replacement = element.content.clone();
222
199
 
223
- // Replace from end to start
224
- for (start, end, src, props, slot_content) in matches.iter().rev() {
225
- // Check if it's an external URL
226
- let replacement_content = if src.starts_with("http://") || src.starts_with("https://") {
227
- // External component - get from cache
228
- external_cache.get(src).cloned()
229
- } else {
230
- // Internal component - get element name from filename (e.g., "card.html" -> "card")
231
- let element_name = src
232
- .trim_start_matches("./")
233
- .trim_start_matches('/')
234
- .split('/')
235
- .last()
236
- .unwrap_or(src)
237
- .trim_end_matches(".html");
238
-
239
- self.cache.get(element_name).map(|e| e.content.clone())
240
- };
241
-
242
- if let Some(mut replacement) = replacement_content {
243
200
  // Replace props: for each prop like headline="@[pageTitle]",
244
201
  // replace @[headline] in content with @[pageTitle]
245
202
  for (prop_name, prop_value) in props {
246
203
  let prop_binding = format!("@[{}]", prop_name);
247
- replacement = replacement.replace(&prop_binding, prop_value);
204
+ replacement = replacement.replace(&prop_binding, &prop_value);
248
205
  }
249
206
 
250
207
  // Replace <slot> tags with content, or remove if empty/missing
251
- let slot_replacement = slot_content
252
- .as_ref()
253
- .filter(|s| !s.trim().is_empty())
254
- .map(|s| s.as_str())
255
- .unwrap_or("");
256
-
208
+ let slot_replacement = if slot_content.trim().is_empty() {
209
+ ""
210
+ } else {
211
+ &slot_content
212
+ };
257
213
  replacement = replacement.replace("<slot></slot>", slot_replacement);
258
214
  replacement = replacement.replace("<slot/>", slot_replacement);
259
215
  replacement = replacement.replace("<slot />", slot_replacement);
260
216
 
261
- result.replace_range(*start..*end, &replacement);
217
+ // Keep the wrapper for consistency with runtime (using generic <component> wrapper)
218
+ // No src attribute = wrapper won't be re-processed
219
+ let wrapper = format!("<component>{}</component>", replacement);
220
+ result.replace_range(start..end, &wrapper);
221
+ }
222
+ }
223
+ }
224
+
225
+ result
226
+ }
227
+
228
+ /// Recursively inline all <component> elements with their HTML content
229
+ /// Inline a single component (one pass, no looping)
230
+ /// Used during recursive component fetching to resolve nested components
231
+ pub fn inline_single_component(&self, content: &str, component_src: &str, component_content: &str) -> String {
232
+ let mut result = content.to_string();
233
+
234
+ // Normalize the src for matching
235
+ let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
236
+ component_src.to_string()
237
+ } else {
238
+ let without_prefix = component_src.trim_start_matches("./");
239
+ if without_prefix.starts_with('/') {
240
+ without_prefix.to_string()
241
+ } else {
242
+ format!("/{}", without_prefix)
243
+ }
244
+ };
245
+
246
+ // Match <component src="..." attrs...>children</component>
247
+ // OR <div class="component" src="..." attrs...>children</div>
248
+ let pattern = format!(r#"(?s)<(component|div)\s+([^>]*)\bsrc="{}"\s*([^>]*)>(.*?)</(component|div)>"#, regex::escape(&normalized_src));
249
+ let component_re = regex::Regex::new(&pattern).unwrap();
250
+
251
+ let matches: Vec<_> = component_re.captures_iter(&result).map(|cap| {
252
+ let full_match = cap.get(0).unwrap();
253
+ let tag_name = cap.get(1).unwrap().as_str();
254
+ let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
255
+ let attrs_after = cap.get(3).map(|m| m.as_str()).unwrap_or("");
256
+ let slot_content = cap.get(4).map(|m| m.as_str().to_string());
257
+
258
+ // For <div>, verify it has class="component"
259
+ if tag_name == "div" {
260
+ let combined_attrs = format!("{} {}", attrs_before, attrs_after);
261
+ if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
262
+ return None; // Not a component div, skip
262
263
  }
263
- // If not found in cache or internal elements, leave as-is
264
+ }
265
+
266
+ // Combine all attributes
267
+ let attrs_str = format!("{} {}", attrs_before, attrs_after);
268
+ let props = Self::parse_props(&attrs_str);
269
+
270
+ Some((full_match.start(), full_match.end(), props, slot_content))
271
+ }).flatten().collect();
272
+
273
+ // Replace from end to start
274
+ for (start, end, props, slot_content) in matches.iter().rev() {
275
+ let mut replacement = component_content.to_string();
276
+
277
+ // Replace props
278
+ for (prop_name, prop_value) in props {
279
+ let prop_binding = format!("@[{}]", prop_name);
280
+ replacement = replacement.replace(&prop_binding, prop_value);
281
+ }
282
+
283
+ // Replace slots
284
+ let slot_replacement = slot_content
285
+ .as_ref()
286
+ .filter(|s| !s.trim().is_empty())
287
+ .map(|s| s.as_str())
288
+ .unwrap_or("");
289
+
290
+ replacement = replacement.replace("<slot></slot>", slot_replacement);
291
+ replacement = replacement.replace("<slot/>", slot_replacement);
292
+ replacement = replacement.replace("<slot />", slot_replacement);
293
+
294
+ // Keep the <component> wrapper
295
+ let wrapper = format!("<component>{}</component>", replacement);
296
+ result.replace_range(*start..*end, &wrapper);
297
+ }
298
+
299
+ result
300
+ }
301
+
302
+ fn inline_component_elements(&self, content: &str, external_cache: &HashMap<String, String>) -> String {
303
+ let mut result = content.to_string();
304
+
305
+ // Single pass - all components in cache are already fully resolved
306
+ // Match <component src="..." attrs...>children</component>
307
+ // OR <div class="component" src="..." attrs...>children</div> (after accessibility transformation)
308
+ // Both signatures require src attribute (wrappers without src are already processed)
309
+ let component_re = regex::Regex::new(
310
+ r#"(?s)<(component|div)\s+([^>]*)\bsrc="([^"]+)"([^>]*)>(.*?)</(component|div)>"#
311
+ ).unwrap();
312
+
313
+ let matches: Vec<_> = component_re.captures_iter(&result).map(|cap| {
314
+ let full_match = cap.get(0).unwrap();
315
+ let tag_name = cap.get(1).unwrap().as_str();
316
+ let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
317
+ let src = cap.get(3).unwrap().as_str();
318
+ let attrs_after = cap.get(4).map(|m| m.as_str()).unwrap_or("");
319
+ let slot_content = cap.get(5).map(|m| m.as_str().to_string());
320
+
321
+ // For <div>, verify it has class="component" (or class="component other-classes")
322
+ if tag_name == "div" {
323
+ let combined_attrs = format!("{} {}", attrs_before, attrs_after);
324
+ if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
325
+ return None; // Not a component div, skip
326
+ }
327
+ }
328
+
329
+ // Combine all attributes (excluding src which we already extracted)
330
+ let attrs_str = format!("{} {}", attrs_before, attrs_after);
331
+ let props = Self::parse_props(&attrs_str);
332
+
333
+ Some((full_match.start(), full_match.end(), src.to_string(), props, slot_content))
334
+ }).flatten().collect();
335
+
336
+ // Replace from end to start
337
+ for (start, end, src, props, slot_content) in matches.iter().rev() {
338
+ // Normalize path
339
+ let normalized_src = if src.starts_with("http://") || src.starts_with("https://") {
340
+ src.to_string()
341
+ } else {
342
+ let without_prefix = src.trim_start_matches("./");
343
+ if without_prefix.starts_with('/') {
344
+ without_prefix.to_string()
345
+ } else {
346
+ format!("/{}", without_prefix)
347
+ }
348
+ };
349
+
350
+ // Get fully resolved component from cache
351
+ if let Some(mut replacement) = external_cache.get(&normalized_src).cloned() {
352
+ // Replace props
353
+ for (prop_name, prop_value) in props {
354
+ let prop_binding = format!("@[{}]", prop_name);
355
+ replacement = replacement.replace(&prop_binding, prop_value);
356
+ }
357
+
358
+ // Replace slots
359
+ let slot_replacement = slot_content
360
+ .as_ref()
361
+ .filter(|s| !s.trim().is_empty())
362
+ .map(|s| s.as_str())
363
+ .unwrap_or("");
364
+
365
+ replacement = replacement.replace("<slot></slot>", slot_replacement);
366
+ replacement = replacement.replace("<slot/>", slot_replacement);
367
+ replacement = replacement.replace("<slot />", slot_replacement);
368
+
369
+ // Keep the <component> wrapper
370
+ let wrapper = format!("<component>{}</component>", replacement);
371
+ result.replace_range(*start..*end, &wrapper);
264
372
  }
265
373
  }
266
374
 
@@ -286,7 +394,7 @@ impl HtmlParser {
286
394
  }
287
395
 
288
396
  /// Transform custom HTML elements to divs with classes
289
- fn transform_custom_tags_to_divs(content: &str, exclude_tags: &[String]) -> String {
397
+ fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) -> String {
290
398
  let mut result = content.to_string();
291
399
 
292
400
  // Standard HTML5 elements (should not be transformed)
@@ -318,13 +426,13 @@ fn transform_custom_tags_to_divs(content: &str, exclude_tags: &[String]) -> Stri
318
426
  let tag_pattern = regex::Regex::new(r"<([a-z][a-z0-9-]*)([^>]*)>").unwrap();
319
427
  let _closing_pattern = regex::Regex::new(r"</([a-z][a-z0-9-]*)>").unwrap();
320
428
 
321
- // Collect unique custom tags first
429
+ // Collect unique custom tags (excluding standard HTML tags and reserved elements)
322
430
  let mut custom_tags: Vec<String> = Vec::new();
323
431
  for cap in tag_pattern.captures_iter(&result.clone()) {
324
432
  if let Some(m) = cap.get(1) {
325
433
  let tag = m.as_str().to_string();
326
434
  if !standard_tags.contains(tag.as_str())
327
- && !exclude_tags.contains(&tag)
435
+ && !reserved_elements.contains(&tag)
328
436
  && !custom_tags.contains(&tag)
329
437
  {
330
438
  custom_tags.push(tag);
@@ -345,5 +453,8 @@ fn transform_custom_tags_to_divs(content: &str, exclude_tags: &[String]) -> Stri
345
453
  result = close_re.replace_all(&result, "</div>").to_string();
346
454
  }
347
455
 
456
+ // Note: <component> tags are NOT transformed - they are a framework element, not a custom element
457
+ // Only user-defined custom elements are transformed to divs
458
+
348
459
  result
349
460
  }
package/component.js CHANGED
@@ -11,33 +11,45 @@
11
11
  // Or with class:
12
12
  // <div class="component">...</div>
13
13
 
14
- import { generateComponentId } from './runtime/component-state.js';
14
+ import { generateComponentId } from './runtime/component.js';
15
15
  import { ensureBoot } from './boot.js';
16
16
 
17
17
  const component = (state = {}, config, targetSelector) => {
18
+ // Initialize component registry
19
+ if (!window.__vibeComponents) {
20
+ window.__vibeComponents = {};
21
+ }
22
+
18
23
  // Find the first unprocessed INLINE component wrapper
19
24
  // Skip <component src=""> (fetched components) - they don't need state tagging
20
25
  // Scripts execute in DOM order, so we claim wrappers in DOM order too
21
26
  // Supports: <component> or <div class="component">
22
27
  const allWrappers = Array.from(document.querySelectorAll('component:not([src]), div.component:not([src])'));
23
- const wrapper = allWrappers.find(el => !el.hasAttribute('data-vibe-component-id'));
28
+
29
+ // Try to find unprocessed component (runtime mode)
30
+ let wrapper = allWrappers.find(el => !el.hasAttribute('data-vibe-component-id'));
31
+
32
+ // If not found, try to find pre-compiled component that hasn't registered state yet (compiled mode)
33
+ if (!wrapper) {
34
+ wrapper = allWrappers.find(el => {
35
+ const existingId = el.getAttribute('data-vibe-component-id');
36
+ return existingId && !window.__vibeComponents[existingId];
37
+ });
38
+ }
24
39
 
25
40
  if (!wrapper) {
26
41
  console.warn('[vibe] component() must be called inside <component> or <div class="component">');
27
42
  return;
28
43
  }
29
44
 
30
- // Generate unique component ID
31
- const componentId = generateComponentId();
32
-
33
- // Tag only the wrapper element
34
- // All descendants will find it via element.closest('[data-vibe-component-id]')
35
- wrapper.setAttribute('data-vibe-component-id', componentId);
45
+ // Get or generate component ID
46
+ let componentId = wrapper.getAttribute('data-vibe-component-id');
47
+ if (!componentId) {
48
+ componentId = generateComponentId();
49
+ wrapper.setAttribute('data-vibe-component-id', componentId);
50
+ }
36
51
 
37
52
  // Register component state in shared registry
38
- if (!window.__vibeComponents) {
39
- window.__vibeComponents = {};
40
- }
41
53
  window.__vibeComponents[componentId] = state;
42
54
 
43
55
  // Store config (first caller wins)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.3.2",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -1,5 +1,5 @@
1
1
  import { debugLog } from './debug.js';
2
- import { PHASE_READY, FOUC_CLASS_OR_ATTR } from './constants.js';
2
+ import { PHASE_READY, FOUC_CLASS_OR_ATTR, DEHYDRATE_CLASS_OR_ATTR } from './constants.js';
3
3
 
4
4
  /**
5
5
  * Check if all Vibe processing is complete and cleanup can run
@@ -20,7 +20,7 @@ export const shouldCleanup = (rootElement) => {
20
20
  // Check if this text node is inside a dehydrated element
21
21
  let parent = node.parentElement;
22
22
  while (parent && parent !== rootElement) {
23
- if (parent.hasAttribute('dehydrate')) {
23
+ if (parent.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || parent.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
24
24
  return NodeFilter.FILTER_REJECT; // Skip dehydrated content
25
25
  }
26
26
  parent = parent.parentElement;
@@ -57,14 +57,14 @@ export const cleanup = (rootElement, debug = false) => {
57
57
  // Remove class from all elements in document that have it
58
58
  const elements = document.querySelectorAll(`.${cleanName}`);
59
59
  elements.forEach((el) => el.classList.remove(cleanName));
60
- debugLog(PHASE_READY, `removing .${cleanName} class from ${elements.length} elements`, debug);
60
+ debugLog(PHASE_READY, `Removing .${cleanName} class from ${elements.length} ${elements.length === 1 ? 'element' : 'elements'}`, debug);
61
61
  } else {
62
62
  // Remove attribute from all elements in document that have it
63
63
  const elements = document.querySelectorAll(`[${cleanName}]`);
64
64
  elements.forEach((el) => el.removeAttribute(cleanName));
65
65
  debugLog(
66
66
  PHASE_READY,
67
- `removing [${cleanName}] attribute from ${elements.length} elements`,
67
+ `Removing [${cleanName}] attribute from ${elements.length} ${elements.length === 1 ? 'element' : 'elements'}`,
68
68
  debug,
69
69
  );
70
70
  }