caspian-utils 0.0.20__tar.gz → 0.0.25__tar.gz

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.
Files changed (26) hide show
  1. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/PKG-INFO +1 -1
  2. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/component_decorator.py +49 -4
  3. caspian_utils-0.0.25/casp/components_compiler.py +523 -0
  4. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/layout.py +57 -1
  5. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/caspian_utils.egg-info/PKG-INFO +1 -1
  6. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/setup.py +1 -1
  7. caspian_utils-0.0.20/casp/components_compiler.py +0 -334
  8. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/README.md +0 -0
  9. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/__init__.py +0 -0
  10. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/auth.py +0 -0
  11. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/cache_handler.py +0 -0
  12. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/caspian_config.py +0 -0
  13. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/html_attrs.py +0 -0
  14. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/loading.py +0 -0
  15. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/rpc.py +0 -0
  16. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/scripts_type.py +0 -0
  17. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/state_manager.py +0 -0
  18. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/string_helpers.py +0 -0
  19. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/syntax_compiler.py +0 -0
  20. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/tw.py +0 -0
  21. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/validate.py +0 -0
  22. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/caspian_utils.egg-info/SOURCES.txt +0 -0
  23. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/caspian_utils.egg-info/dependency_links.txt +0 -0
  24. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/caspian_utils.egg-info/requires.txt +0 -0
  25. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/caspian_utils.egg-info/top_level.txt +0 -0
  26. {caspian_utils-0.0.20 → caspian_utils-0.0.25}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caspian-utils
3
- Version: 0.0.20
3
+ Version: 0.0.25
4
4
  Summary: A utility package for Caspian projects
5
5
  Home-page: https://github.com/TheSteelNinjaCode/caspian_utils
6
6
  Author: Jefferson Abraham
@@ -1,6 +1,7 @@
1
1
  from .layout import env, load_template_file
2
- from .html_attrs import get_attributes
2
+ from .html_attrs import get_attributes, merge_classes
3
3
  from .syntax_compiler import transpile_syntax
4
+ from .caspian_config import get_config
4
5
  from typing import Callable, TypeVar, Dict, Optional, Any, Union
5
6
  from markupsafe import Markup
6
7
  import inspect
@@ -27,9 +28,53 @@ class Component:
27
28
  _component_registry[fn.__name__] = self
28
29
 
29
30
  def __call__(self, *args, **kwargs) -> Union[str, Markup]:
30
- # Standardize React-like props to Pythonic names
31
- if "className" in kwargs:
32
- kwargs["class_name"] = kwargs.pop("className")
31
+ # 1. Gather all class variants
32
+ c_raw = kwargs.get("class")
33
+ c_name = kwargs.get("class_name")
34
+ c_react = kwargs.pop("className", None) # Remove React-style key
35
+
36
+ # 2. Filter valid inputs
37
+ candidates = []
38
+ for c in [c_raw, c_name, c_react]:
39
+ if c:
40
+ candidates.append(c)
41
+
42
+ # 3. Merge if any exist
43
+ if candidates:
44
+ try:
45
+ # Check config to decide strategy
46
+ cfg = get_config()
47
+ use_tailwind = cfg.tailwindcss
48
+ except Exception:
49
+ # Fallback safety if config isn't loaded
50
+ use_tailwind = False
51
+
52
+ if use_tailwind:
53
+ # Strategy A: Smart Merge (Tailwind)
54
+ merged = merge_classes(*candidates)
55
+ else:
56
+ # Strategy B: Simple Join (Vanilla CSS)
57
+ parts = []
58
+ for c in candidates:
59
+ if isinstance(c, (list, tuple, set)):
60
+ parts.append(" ".join(str(x) for x in c if x))
61
+ else:
62
+ parts.append(str(c).strip())
63
+ merged = " ".join(p for p in parts if p)
64
+
65
+ # A. Always set 'class' (The Standard)
66
+ # This ensures props['class'] is available for get_attributes()
67
+ kwargs["class"] = merged
68
+
69
+ # B. Only set 'class_name' if explicitly requested
70
+ # This prevents duplication in **props
71
+ if "class_name" in self.__signature__.parameters:
72
+ kwargs["class_name"] = merged
73
+ else:
74
+ # If the function didn't ask for it, ensure it's removed
75
+ kwargs.pop("class_name", None)
76
+
77
+ # Standardize other React-like props
33
78
  if "htmlFor" in kwargs:
34
79
  kwargs["html_for"] = kwargs.pop("htmlFor")
35
80
 
@@ -0,0 +1,523 @@
1
+ from .string_helpers import camel_to_kebab
2
+ from .string_helpers import kebab_to_camel
3
+ from .string_helpers import has_mustache
4
+ import re
5
+ import uuid
6
+ import inspect
7
+ from typing import Dict, List, Optional, Tuple
8
+ from .component_decorator import Component, load_component_from_path
9
+ import os
10
+
11
+
12
+ # ============================================================================
13
+ # HELPER: Mustache & Attributes
14
+ # ============================================================================
15
+
16
+ def convert_mustache_attrs_to_kebab_raw(html):
17
+ """
18
+ Converts mustache-bound attributes to kebab-case.
19
+ e.g. someProp={value} -> some-prop={value}
20
+ """
21
+ def replace_attr(match):
22
+ try:
23
+ attr_name = match.group(1)
24
+ equals_quote = match.group(2)
25
+ value = match.group(3)
26
+ end_quote = match.group(4)
27
+ except IndexError:
28
+ return match.group(0)
29
+
30
+ if has_mustache(value):
31
+ new_name = camel_to_kebab(attr_name)
32
+ return f'{new_name}{equals_quote}{value}{end_quote}'
33
+ return match.group(0)
34
+
35
+ pattern = r'([a-zA-Z_][a-zA-Z0-9_]*)(=["\'])([^"\']*?)(["\'])'
36
+ return re.sub(pattern, replace_attr, html)
37
+
38
+
39
+ # ============================================================================
40
+ # IMPORT PARSING
41
+ # ============================================================================
42
+
43
+ def extract_imports(html: str) -> List[Dict]:
44
+ """
45
+ Extract all import statements from HTML.
46
+
47
+ Supports:
48
+ <!-- @import Test from "./path" -->
49
+ <!-- @import Test from ./path --> (unquoted)
50
+ <!-- @import Test as MyTest from "./path" -->
51
+ <!-- @import { Button, Input } from "./path" -->
52
+ <!-- @import { Button as Btn, Input as Field } from "./path" -->
53
+ """
54
+ imports = []
55
+
56
+ # Grouped imports
57
+ grouped_pattern = r'<!--\s*@import\s*\{([^}]+)\}\s*from\s*["\']?([^"\'>\s]+)["\']?\s*-->'
58
+ grouped_re = re.compile(grouped_pattern)
59
+ for match in grouped_re.finditer(html):
60
+ try:
61
+ members = match.group(1)
62
+ path = match.group(2)
63
+ except IndexError:
64
+ continue
65
+
66
+ for member in members.split(','):
67
+ member = member.strip()
68
+ if not member:
69
+ continue
70
+ if ' as ' in member:
71
+ parts = member.split(' as ')
72
+ original = parts[0].strip()
73
+ alias = parts[1].strip()
74
+ else:
75
+ original = member
76
+ alias = member
77
+ imports.append(
78
+ {'original': original, 'alias': alias, 'path': path})
79
+
80
+ # Single imports
81
+ single_pattern = r'<!--\s*@import\s+(?!\{)(\w+)(?:\s+as\s+(\w+))?\s+from\s*["\']?([^"\'>\s]+)["\']?\s*-->'
82
+ single_re = re.compile(single_pattern)
83
+ for match in single_re.finditer(html):
84
+ try:
85
+ original = match.group(1)
86
+ alias = match.group(2) or original
87
+ path = match.group(3)
88
+ except IndexError:
89
+ continue
90
+ imports.append({'original': original, 'alias': alias, 'path': path})
91
+
92
+ return imports
93
+
94
+
95
+ def strip_imports(html: str) -> str:
96
+ """Remove import comments from HTML"""
97
+ html = re.sub(
98
+ r'<!--\s*@import\s*\{[^}]+\}\s*from\s*["\']?[^"\'>\s]+["\']?\s*-->\n?', '', html
99
+ )
100
+ html = re.sub(
101
+ r'<!--\s*@import\s+\w+(?:\s+as\s+\w+)?\s+from\s*["\']?[^"\'>\s]+["\']?\s*-->\n?', '', html
102
+ )
103
+ return html
104
+
105
+
106
+ def build_alias_map(imports: List[Dict], base_dir: str = "") -> Dict[str, Component]:
107
+ alias_map = {}
108
+ for imp in imports:
109
+ comp = load_component_from_path(imp['path'], imp['original'], base_dir)
110
+ if comp:
111
+ alias_map[imp['alias']] = comp
112
+ return alias_map
113
+
114
+
115
+ def process_imports(html: str, base_dir: str = "") -> tuple[str, Dict[str, Component]]:
116
+ imports = extract_imports(html)
117
+ alias_map = build_alias_map(imports, base_dir)
118
+ cleaned_html = strip_imports(html)
119
+ return cleaned_html, alias_map
120
+
121
+
122
+ # ============================================================================
123
+ # COMPONENT HELPERS
124
+ # ============================================================================
125
+
126
+ def needs_parent_scope(attrs):
127
+ for key, value in attrs.items():
128
+ if key.lower().startswith('on'):
129
+ return True
130
+ if has_mustache(value):
131
+ return True
132
+ return False
133
+
134
+
135
+ def wrap_scope(html, scope):
136
+ return f'<!-- pp-scope:{scope} -->{html}<!-- /pp-scope -->'
137
+
138
+
139
+ def accepts_children(fn):
140
+ sig = fn.__signature__ if isinstance(
141
+ fn, Component) else inspect.signature(fn)
142
+ params = sig.parameters
143
+ return 'children' in params or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
144
+
145
+
146
+ def _event_listener_props(props: Dict[str, str]) -> Dict[str, str]:
147
+ return {k: v for k, v in props.items() if k.lower().startswith("on")}
148
+
149
+
150
+ def _event_attr_names(event_prop: str) -> Tuple[str, str]:
151
+ return event_prop, camel_to_kebab(event_prop)
152
+
153
+
154
+ _RAWTEXT_ROOT_TAGS = {"textarea", "title"}
155
+
156
+
157
+ def _inject_component_scope_inside_root(html: str, component_scope: str) -> str:
158
+ s = str(html).strip()
159
+ if not s:
160
+ return s
161
+
162
+ tag_name, _, is_self_closing, end_pos = _scan_opening_tag(s, 0)
163
+ if not tag_name:
164
+ return wrap_scope(s, component_scope)
165
+
166
+ if tag_name.lower() in _RAWTEXT_ROOT_TAGS or is_self_closing:
167
+ return wrap_scope(s, component_scope)
168
+
169
+ close_start = _scan_closing_tag(s, tag_name, end_pos)
170
+ if close_start == -1:
171
+ return wrap_scope(s, component_scope)
172
+
173
+ return s[:end_pos] + f"<!-- pp-scope:{component_scope} -->" + s[end_pos:close_start] + "<!-- /pp-scope -->" + s[close_start:]
174
+
175
+
176
+ def _wrap_event_elements_with_parent_scope(html: str, event_listeners: Dict[str, str], parent_scope: str) -> str:
177
+ if not event_listeners or not parent_scope:
178
+ return str(html)
179
+ s = str(html)
180
+ wraps: List[Tuple[int, int, str]] = []
181
+
182
+ for event_prop, handler in event_listeners.items():
183
+ _, kebab_name = _event_attr_names(event_prop)
184
+ camel_name = event_prop
185
+
186
+ # Locate attribute usage
187
+ attr_pat = re.compile(
188
+ rf"<([a-zA-Z][\w:-]*)\b[^>]*\s(?:{re.escape(camel_name)}|{re.escape(kebab_name)})\s*=\s*(\"([^\"]*)\"|'([^']*)')[^>]*>",
189
+ re.IGNORECASE,
190
+ )
191
+
192
+ for m in attr_pat.finditer(s):
193
+ val = m.group(3) if m.group(3) is not None else (m.group(4) or "")
194
+ if val != handler:
195
+ continue
196
+
197
+ tag = m.group(1)
198
+ start = m.start()
199
+ _, _, is_self, open_end = _scan_opening_tag(s, start)
200
+
201
+ if is_self:
202
+ wraps.append((start, open_end, wrap_scope(
203
+ s[start:open_end], parent_scope)))
204
+ continue
205
+
206
+ close_start = _scan_closing_tag(s, tag, open_end)
207
+ if close_start == -1:
208
+ continue
209
+
210
+ close_end = s.find(">", close_start) + 1
211
+ if close_end == 0:
212
+ continue
213
+
214
+ wraps.append((start, close_end, wrap_scope(
215
+ s[start:close_end], parent_scope)))
216
+
217
+ if not wraps:
218
+ return s
219
+
220
+ for start, end, repl in sorted(wraps, key=lambda x: x[0], reverse=True):
221
+ s = s[:start] + repl + s[end:]
222
+
223
+ return s
224
+
225
+
226
+ def _get_root_tag_name(fragment: str) -> str:
227
+ tag, _, _, _ = _scan_opening_tag(str(fragment).strip(), 0)
228
+ return tag.lower() if tag else ""
229
+
230
+
231
+ # ============================================================================
232
+ # ROBUST PARSING (FIXED LOGIC)
233
+ # ============================================================================
234
+
235
+ def _scan_opening_tag(html: str, start_pos: int) -> Tuple[Optional[str], str, bool, int]:
236
+ """
237
+ Scans a tag starting at `start_pos`.
238
+ Returns: (tag_name, attributes_str, is_self_closing, end_pos)
239
+ """
240
+ length = len(html)
241
+ if start_pos >= length or html[start_pos] != '<':
242
+ return None, "", False, start_pos
243
+
244
+ # 1. Extract Tag Name
245
+ i = start_pos + 1
246
+ while i < length and (html[i].isalnum() or html[i] in '_-:'):
247
+ i += 1
248
+
249
+ tag_name = html[start_pos+1:i]
250
+ if not tag_name:
251
+ return None, "", False, start_pos
252
+
253
+ # 2. Scan Attributes (State Machine)
254
+ # We only respect quotes INSIDE the tag definition.
255
+ in_dquote = False
256
+ in_squote = False
257
+ attrs_start = i
258
+
259
+ while i < length:
260
+ char = html[i]
261
+
262
+ if in_dquote:
263
+ if char == '"':
264
+ in_dquote = False
265
+ elif in_squote:
266
+ if char == "'":
267
+ in_squote = False
268
+ else:
269
+ if char == '"':
270
+ in_dquote = True
271
+ elif char == "'":
272
+ in_squote = True
273
+ elif char == '>':
274
+ is_self_closing = (html[i-1] == '/')
275
+ attrs_end = i - 1 if is_self_closing else i
276
+ attrs_str = html[attrs_start:attrs_end]
277
+ return tag_name, attrs_str, is_self_closing, i + 1
278
+ i += 1
279
+
280
+ return None, "", False, start_pos
281
+
282
+
283
+ def _scan_closing_tag(html: str, tag_name: str, start_pos: int) -> int:
284
+ """
285
+ Finds the index of the matching `</tag_name>`.
286
+ Handles nested tags.
287
+ Crucially: Does NOT track quotes when scanning text content (fixing "you're" bug).
288
+ """
289
+ depth = 1
290
+ length = len(html)
291
+ i = start_pos
292
+
293
+ target_close = f"</{tag_name}"
294
+ target_open = f"<{tag_name}"
295
+
296
+ while i < length:
297
+ # Jump to next potential tag start
298
+ lt = html.find('<', i)
299
+ if lt == -1:
300
+ break
301
+
302
+ # Check what kind of tag it is
303
+ # 1. Closing Tag?
304
+ if html.startswith(target_close, lt):
305
+ # Check boundary (ensure it's </Button> not </Buttons>)
306
+ after = lt + len(target_close)
307
+ if after < length and html[after] in '> \t\n\r':
308
+ depth -= 1
309
+ if depth == 0:
310
+ return lt
311
+ # It was a nested closing tag, just skip it
312
+ i = html.find('>', after) + 1
313
+ continue
314
+
315
+ # 2. Opening Tag? (Nesting)
316
+ if html.startswith(target_open, lt):
317
+ after = lt + len(target_open)
318
+ if after < length and html[after] in '> \t\n\r/':
319
+ # We need to check if it is self-closing
320
+ # We use _scan_opening_tag to correctly parse attributes and find the end
321
+ _, _, is_self, end_nested = _scan_opening_tag(html, lt)
322
+ if not is_self:
323
+ depth += 1
324
+ i = end_nested
325
+ continue
326
+
327
+ # 3. Comment?
328
+ if html.startswith("<!--", lt):
329
+ end_comment = html.find("-->", lt)
330
+ if end_comment != -1:
331
+ i = end_comment + 3
332
+ continue
333
+
334
+ # 4. Any other tag?
335
+ # We must skip it safely so we don't get confused by '>' inside its attributes.
336
+ # e.g. <div title=">">
337
+ if lt + 1 < length and (html[lt+1].isalnum() or html[lt+1] == '/'):
338
+ # Attempt to parse as a generic tag to find its end safely
339
+ _, _, _, end_other = _scan_opening_tag(html, lt)
340
+ # If parsing failed (malformed), just advance by 1 to avoid infinite loop
341
+ i = end_other if end_other > lt else lt + 1
342
+ else:
343
+ i = lt + 1
344
+
345
+ return -1
346
+
347
+
348
+ # ============================================================================
349
+ # RAW BLOCK PROTECTION
350
+ # ============================================================================
351
+
352
+ _RAW_TAGS_DEFAULT = ("script", "style")
353
+
354
+
355
+ def _mask_raw_blocks(html: str, tags: Tuple[str, ...] = _RAW_TAGS_DEFAULT) -> Tuple[str, List[Tuple[str, str]]]:
356
+ blocks: List[Tuple[str, str]] = []
357
+ for tag in tags:
358
+ pattern = re.compile(
359
+ rf'<{tag}\b[^>]*>[\s\S]*?</{tag}\s*>', re.IGNORECASE)
360
+
361
+ def repl(m):
362
+ token = f"__PP_RAW_{tag.upper()}_{uuid.uuid4().hex}__"
363
+ blocks.append((token, m.group(0)))
364
+ return token
365
+ html = pattern.sub(repl, html)
366
+ return html, blocks
367
+
368
+
369
+ def _unmask_raw_blocks(html: str, blocks: List[Tuple[str, str]]) -> str:
370
+ for token, original in blocks:
371
+ html = html.replace(token, original)
372
+ return html
373
+
374
+
375
+ # ============================================================================
376
+ # MAIN TRANSFORM
377
+ # ============================================================================
378
+
379
+ def transform_components(
380
+ html: str,
381
+ parent_scope: str = "app",
382
+ base_dir: str = "",
383
+ alias_map: Optional[Dict[str, Component]] = None,
384
+ _depth: int = 0
385
+ ) -> str:
386
+ if _depth > 50:
387
+ return html
388
+
389
+ # 0. Mask script/style blocks
390
+ html, raw_blocks = _mask_raw_blocks(html)
391
+
392
+ # 1. Process imports
393
+ html, local_alias_map = process_imports(html, base_dir)
394
+ merged_map = {**(alias_map or {}), **local_alias_map}
395
+
396
+ if not merged_map:
397
+ return _unmask_raw_blocks(html, raw_blocks)
398
+
399
+ # 2. Attributes
400
+ html = convert_mustache_attrs_to_kebab_raw(html)
401
+
402
+ # 3. Main Loop
403
+ max_iterations = 100
404
+ iteration = 0
405
+
406
+ while iteration < max_iterations:
407
+ iteration += 1
408
+
409
+ # Optimization: Find candidates via regex, parse via lexer
410
+ candidate_pattern = re.compile(r'<([A-Z][a-zA-Z0-9]*)')
411
+ best_match = None
412
+
413
+ for m in candidate_pattern.finditer(html):
414
+ name = m.group(1)
415
+ if name in merged_map:
416
+ best_match = (m.start(), name)
417
+ break
418
+
419
+ if not best_match:
420
+ break
421
+
422
+ found_start, found_tag_name = best_match
423
+ component_fn = merged_map[found_tag_name]
424
+
425
+ tag_name, attrs_str, is_self_closing, open_end = _scan_opening_tag(
426
+ html, found_start)
427
+ if not tag_name:
428
+ break
429
+
430
+ children_html = ""
431
+ full_end = open_end
432
+
433
+ if not is_self_closing:
434
+ close_start = _scan_closing_tag(html, tag_name, open_end)
435
+ if close_start != -1:
436
+ children_html = html[open_end:close_start]
437
+ full_end = html.find('>', close_start) + 1
438
+ else:
439
+ print(
440
+ f"[Caspian] Warning: Unclosed component <{tag_name}> found.")
441
+ break
442
+
443
+ # Props Parsing
444
+ props: Dict[str, str] = {}
445
+ attr_pattern = r'([a-zA-Z_][\w\-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|(\S+))'
446
+
447
+ for attr_match in re.finditer(attr_pattern, attrs_str):
448
+ try:
449
+ attr_name = attr_match.group(1)
450
+ val = attr_match.group(2) if attr_match.group(2) is not None else \
451
+ attr_match.group(3) if attr_match.group(3) is not None else \
452
+ attr_match.group(4) or ""
453
+ props[kebab_to_camel(attr_name)] = val
454
+ except IndexError:
455
+ continue
456
+
457
+ # Boolean props
458
+ attrs_clean = re.sub(r'"[^"]*"|\'[^\']*\'', '""', attrs_str)
459
+ bool_attr_pattern = r'\s([a-zA-Z_][\w\-]*)(?=\s|/?>|$)(?!=)'
460
+ for bool_match in re.finditer(bool_attr_pattern, attrs_clean):
461
+ try:
462
+ camel = kebab_to_camel(bool_match.group(1))
463
+ if camel not in props:
464
+ props[camel] = ""
465
+ except IndexError:
466
+ continue
467
+
468
+ unique_id = f"{tag_name.lower()}_{uuid.uuid4().hex[:8]}"
469
+
470
+ if children_html and accepts_children(component_fn):
471
+ props['children'] = wrap_scope(children_html, parent_scope)
472
+
473
+ try:
474
+ component_html = component_fn(**props)
475
+ except Exception as e:
476
+ print(f"[Caspian] Error rendering <{tag_name}>: {e}")
477
+ break
478
+
479
+ # Recursion
480
+ if hasattr(component_fn, 'source_path') and component_fn.source_path:
481
+ component_dir = os.path.dirname(component_fn.source_path)
482
+ component_html = transform_components(
483
+ component_html,
484
+ parent_scope=unique_id,
485
+ base_dir=component_dir,
486
+ _depth=_depth + 1
487
+ )
488
+
489
+ # Inject ID
490
+ component_html = re.sub(
491
+ r'^(\s*<[a-zA-Z][a-zA-Z0-9]*)',
492
+ rf'\1 pp-component="{unique_id}"',
493
+ str(component_html).strip(),
494
+ count=1
495
+ )
496
+
497
+ # Scoping
498
+ root_tag = _get_root_tag_name(component_html)
499
+ needs_parent = needs_parent_scope(props)
500
+ event_listeners = _event_listener_props(props)
501
+
502
+ if root_tag in _RAWTEXT_ROOT_TAGS:
503
+ if needs_parent and parent_scope:
504
+ component_html = wrap_scope(component_html, parent_scope)
505
+ else:
506
+ component_html = wrap_scope(component_html, unique_id)
507
+ else:
508
+ if needs_parent and parent_scope:
509
+ component_html = _inject_component_scope_inside_root(
510
+ component_html, unique_id)
511
+ if event_listeners:
512
+ component_html = _wrap_event_elements_with_parent_scope(
513
+ component_html,
514
+ event_listeners=event_listeners,
515
+ parent_scope=parent_scope,
516
+ )
517
+ component_html = wrap_scope(component_html, parent_scope)
518
+ else:
519
+ component_html = wrap_scope(component_html, unique_id)
520
+
521
+ html = html[:found_start] + component_html + html[full_end:]
522
+
523
+ return _unmask_raw_blocks(html, raw_blocks)
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+ from typing import Literal
2
3
  from typing import Callable
3
4
  import os
4
5
  import json
@@ -11,7 +12,8 @@ from jinja2 import Environment, BaseLoader, select_autoescape
11
12
  from markupsafe import Markup
12
13
  import contextvars
13
14
 
14
- _runtime_metadata = contextvars.ContextVar("casp_metadata", default=None)
15
+ _runtime_metadata: contextvars.ContextVar[Optional["Metadata"]] = contextvars.ContextVar("casp_metadata", default=None)
16
+ _runtime_injections: contextvars.ContextVar[Optional[Dict[str, Any]]] = contextvars.ContextVar("casp_injections", default=None)
15
17
 
16
18
  class LayoutEngine:
17
19
  """
@@ -424,6 +426,18 @@ class LayoutEngine:
424
426
  if component_compiler:
425
427
  current_html = component_compiler(current_html, base_dir=layout.dir_path)
426
428
 
429
+ injections = _runtime_injections.get()
430
+ if injections:
431
+ if injections.get('head'):
432
+ head_block = "\n".join(injections['head'])
433
+ # Regex replace might be safer, but simple string replace is faster
434
+ # and covers 99% of valid HTML cases
435
+ current_html = current_html.replace("</head>", f"{head_block}\n</head>")
436
+
437
+ if injections.get('body'):
438
+ body_block = "\n".join(injections['body'])
439
+ current_html = current_html.replace("</body>", f"{body_block}\n</body>")
440
+
427
441
  if transform_fn:
428
442
  current_html = transform_fn(current_html)
429
443
 
@@ -536,6 +550,46 @@ def render_with_nested_layouts(
536
550
  component_compiler=component_compiler,
537
551
  )
538
552
 
553
+ def inject_html(file_path: str, target: Literal['head', 'body'] = 'head', **kwargs) -> None:
554
+ """
555
+ Inject HTML content from a file into the <head> or <body> of the final render.
556
+
557
+ Args:
558
+ file_path: Path to the HTML file (relative to the calling python file).
559
+ target: Where to inject ('head' or 'body').
560
+ **kwargs: Variables to replace in the file using [[ var ]] syntax.
561
+ """
562
+ # 1. Resolve path relative to caller
563
+ frame = inspect.currentframe()
564
+ base_dir = None
565
+ if frame and frame.f_back:
566
+ # Go back to the caller frame
567
+ caller_file = frame.f_back.f_globals.get('__file__')
568
+ if caller_file:
569
+ base_dir = os.path.dirname(os.path.abspath(caller_file))
570
+
571
+ if base_dir and not os.path.isabs(file_path):
572
+ full_path = os.path.join(base_dir, file_path)
573
+ else:
574
+ full_path = file_path
575
+
576
+ # 2. Load and Render with Caspian String Env
577
+ try:
578
+ if os.path.exists(full_path):
579
+ content = load_template_file(full_path)
580
+ # Use string_env to support [[ content_id ]] params
581
+ rendered_content = string_env.from_string(content).render(**kwargs)
582
+
583
+ # 3. Store in Context
584
+ store = _runtime_injections.get()
585
+ if store is not None:
586
+ tgt = target.lower()
587
+ if tgt in store:
588
+ store[tgt].append(rendered_content)
589
+ else:
590
+ print(f"[Caspian] Warning: inject_html file not found: {full_path}")
591
+ except Exception as e:
592
+ print(f"[Caspian] Error injecting html from {file_path}: {e}")
539
593
 
540
594
  # Back-compat alias (if referenced elsewhere)
541
595
  render_with_layouts = render_with_nested_layouts
@@ -558,4 +612,6 @@ __all__ = [
558
612
  "get_loading_files",
559
613
  "render_with_nested_layouts",
560
614
  "render_with_layouts",
615
+ "inject_html",
616
+ "_runtime_injections",
561
617
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caspian-utils
3
- Version: 0.0.20
3
+ Version: 0.0.25
4
4
  Summary: A utility package for Caspian projects
5
5
  Home-page: https://github.com/TheSteelNinjaCode/caspian_utils
6
6
  Author: Jefferson Abraham
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name='caspian-utils',
8
- version='0.0.20',
8
+ version='0.0.25',
9
9
  author='Jefferson Abraham',
10
10
  packages=find_packages(),
11
11
  install_requires=[
@@ -1,334 +0,0 @@
1
- from .string_helpers import camel_to_kebab
2
- from .string_helpers import kebab_to_camel
3
- from .string_helpers import has_mustache
4
- import re
5
- import uuid
6
- import inspect
7
- from typing import Dict, List, Optional, Tuple
8
- from .component_decorator import Component, load_component_from_path
9
- import os
10
-
11
-
12
- def convert_mustache_attrs_to_kebab_raw(html):
13
- def replace_attr(match):
14
- attr_name = match.group(1)
15
- equals_quote = match.group(2)
16
- value = match.group(3)
17
- end_quote = match.group(4)
18
-
19
- if has_mustache(value):
20
- new_name = camel_to_kebab(attr_name)
21
- return f'{new_name}{equals_quote}{value}{end_quote}'
22
- return match.group(0)
23
-
24
- pattern = r'([a-zA-Z_][a-zA-Z0-9_]*)(=["\'])([^"\']*?)(["\'])'
25
- return re.sub(pattern, replace_attr, html)
26
-
27
-
28
- def extract_component_original_attrs(html, pattern):
29
- component_attrs_map = {}
30
-
31
- for match in re.finditer(pattern, html):
32
- component_name = match.group(1)
33
- tag_start = match.start()
34
-
35
- remaining = html[tag_start:]
36
- tag_end_match = re.search(r'/?>', remaining)
37
- if tag_end_match:
38
- full_tag = remaining[:tag_end_match.end()]
39
-
40
- attr_pattern = re.compile(
41
- r'([a-zA-Z_][\w\-]*)\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)'
42
- )
43
- attrs_original = {}
44
- for attr_match in attr_pattern.finditer(full_tag):
45
- attr_name = attr_match.group(1)
46
- attrs_original[attr_name.lower()] = attr_name
47
-
48
- placeholder = f"ppcomponent{component_name.lower()}"
49
- if placeholder not in component_attrs_map:
50
- component_attrs_map[placeholder] = []
51
- component_attrs_map[placeholder].append(attrs_original)
52
-
53
- return component_attrs_map
54
-
55
-
56
- # ============================================================================
57
- # IMPORT PARSING
58
- # ============================================================================
59
-
60
- def extract_imports(html: str) -> List[Dict]:
61
- """
62
- Extract all import statements from HTML.
63
-
64
- Supports:
65
- <!-- @import Test from "./path" -->
66
- <!-- @import Test from ./path --> (unquoted)
67
- <!-- @import Test as MyTest from "./path" -->
68
- <!-- @import { Button, Input } from "./path" -->
69
- <!-- @import { Button as Btn, Input as Field } from "./path" -->
70
- """
71
- imports = []
72
-
73
- grouped_pattern = r'<!--\s*@import\s*\{([^}]+)\}\s*from\s*["\']?([^"\'>\s]+)["\']?\s*-->'
74
- grouped_re = re.compile(grouped_pattern)
75
- for match in grouped_re.finditer(html):
76
- members = match.group(1)
77
- path = match.group(2)
78
-
79
- for member in members.split(','):
80
- member = member.strip()
81
- if not member:
82
- continue
83
- if ' as ' in member:
84
- parts = member.split(' as ')
85
- original = parts[0].strip()
86
- alias = parts[1].strip()
87
- else:
88
- original = member
89
- alias = member
90
- imports.append({
91
- 'original': original,
92
- 'alias': alias,
93
- 'path': path
94
- })
95
-
96
- single_pattern = r'<!--\s*@import\s+(?!\{)(\w+)(?:\s+as\s+(\w+))?\s+from\s*["\']?([^"\'>\s]+)["\']?\s*-->'
97
- single_re = re.compile(single_pattern)
98
- for match in single_re.finditer(html):
99
- original = match.group(1)
100
- alias = match.group(2) or original
101
- path = match.group(3)
102
- imports.append({
103
- 'original': original,
104
- 'alias': alias,
105
- 'path': path
106
- })
107
-
108
- return imports
109
-
110
-
111
- def strip_imports(html: str) -> str:
112
- """Remove import comments from HTML"""
113
- html = re.sub(
114
- r'<!--\s*@import\s*\{[^}]+\}\s*from\s*["\']?[^"\'>\s]+["\']?\s*-->\n?', '', html
115
- )
116
- html = re.sub(
117
- r'<!--\s*@import\s+\w+(?:\s+as\s+\w+)?\s+from\s*["\']?[^"\'>\s]+["\']?\s*-->\n?', '', html
118
- )
119
- return html
120
-
121
-
122
- def build_alias_map(imports: List[Dict], base_dir: str = "") -> Dict[str, Component]:
123
- """Build mapping from alias names to Component instances."""
124
- alias_map = {}
125
-
126
- for imp in imports:
127
- original = imp['original']
128
- alias = imp['alias']
129
- path = imp['path']
130
-
131
- component = load_component_from_path(path, original, base_dir)
132
- if component:
133
- alias_map[alias] = component
134
-
135
- return alias_map
136
-
137
-
138
- def process_imports(html: str, base_dir: str = "") -> tuple[str, Dict[str, Component]]:
139
- """Process imports and return cleaned HTML with alias map."""
140
- imports = extract_imports(html)
141
- alias_map = build_alias_map(imports, base_dir)
142
- cleaned_html = strip_imports(html)
143
- return cleaned_html, alias_map
144
-
145
-
146
- # ============================================================================
147
- # COMPONENT HELPERS
148
- # ============================================================================
149
-
150
- def needs_parent_scope(attrs):
151
- for key, value in attrs.items():
152
- if key.lower().startswith('on'):
153
- return True
154
- if has_mustache(value):
155
- return True
156
- return False
157
-
158
-
159
- def wrap_scope(html, scope):
160
- return f'<!-- pp-scope:{scope} -->{html}<!-- /pp-scope -->'
161
-
162
-
163
- def accepts_children(fn):
164
- sig = fn.__signature__ if isinstance(
165
- fn, Component) else inspect.signature(fn)
166
- params = sig.parameters
167
- return 'children' in params or any(
168
- p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
169
- )
170
-
171
-
172
- # ============================================================================
173
- # RAW BLOCK PROTECTION (SCRIPT/STYLE)
174
- # ============================================================================
175
-
176
- _RAW_TAGS_DEFAULT = ("script", "style")
177
-
178
-
179
- def _mask_raw_blocks(html: str, tags: Tuple[str, ...] = _RAW_TAGS_DEFAULT) -> Tuple[str, List[Tuple[str, str]]]:
180
- """
181
- Replace <script>...</script> (and optionally <style>...</style>) blocks with tokens.
182
- This prevents component/import parsing from touching JS/CSS content, including
183
- template literals that may contain "<Button>...</Button>" text.
184
- """
185
- blocks: List[Tuple[str, str]] = []
186
-
187
- for tag in tags:
188
- # Capture the entire block, non-greedy for content
189
- pattern = re.compile(
190
- rf'<{tag}\b[^>]*>[\s\S]*?</{tag}\s*>',
191
- re.IGNORECASE
192
- )
193
-
194
- def repl(m):
195
- token = f"__PP_RAW_{tag.upper()}_{uuid.uuid4().hex}__"
196
- blocks.append((token, m.group(0)))
197
- return token
198
-
199
- html = pattern.sub(repl, html)
200
-
201
- return html, blocks
202
-
203
-
204
- def _unmask_raw_blocks(html: str, blocks: List[Tuple[str, str]]) -> str:
205
- # Restore in insertion order (tokens are unique, so order is not critical)
206
- for token, original in blocks:
207
- html = html.replace(token, original)
208
- return html
209
-
210
-
211
- # ============================================================================
212
- # MAIN TRANSFORM
213
- # ============================================================================
214
-
215
- def transform_components(
216
- html: str,
217
- parent_scope: str = "app",
218
- base_dir: str = "",
219
- alias_map: Optional[Dict[str, Component]] = None,
220
- _depth: int = 0
221
- ) -> str:
222
- """
223
- Transform custom component tags. Components MUST be imported to be resolved.
224
- Recursively processes the output of components to handle nested imports/components.
225
-
226
- CRITICAL GUARANTEE:
227
- - Content inside <script> and <style> tags is never scanned for components/imports.
228
- """
229
- if _depth > 50:
230
- return html
231
-
232
- # 0. Mask script/style blocks so nothing inside them is ever processed
233
- html, raw_blocks = _mask_raw_blocks(html, tags=_RAW_TAGS_DEFAULT)
234
-
235
- # 1. Process imports for the current level (safe: scripts are masked)
236
- html, local_alias_map = process_imports(html, base_dir)
237
-
238
- if alias_map:
239
- merged_map = {**alias_map, **local_alias_map}
240
- else:
241
- merged_map = local_alias_map
242
-
243
- # If no components are imported, return original HTML (but restore scripts)
244
- if not merged_map:
245
- return _unmask_raw_blocks(html, raw_blocks)
246
-
247
- # 2. Convert mustache attributes to kebab-case (safe: scripts are masked)
248
- html = convert_mustache_attrs_to_kebab_raw(html)
249
-
250
- # 3. Process components iteratively
251
- max_iterations = 100
252
- iteration = 0
253
-
254
- while iteration < max_iterations:
255
- iteration += 1
256
-
257
- component_pattern = r'<([A-Z][a-zA-Z0-9]*)([^>]*?)(?:/>|>([\s\S]*?)</\1>)'
258
-
259
- match = None
260
- for m in re.finditer(component_pattern, html):
261
- tag_name = m.group(1)
262
- if tag_name in merged_map:
263
- inner_content = m.group(3) or ""
264
- if not re.search(r'<[A-Z][a-zA-Z0-9]*[^>]*(?:/>|>)', inner_content):
265
- match = m
266
- break
267
- elif match is None:
268
- match = m
269
-
270
- if not match:
271
- break
272
-
273
- tag_name = match.group(1)
274
- attrs_str = match.group(2) or ""
275
- children = match.group(3) or ""
276
-
277
- component_fn = merged_map.get(tag_name)
278
- if not component_fn:
279
- break
280
-
281
- # 4. Parse attributes
282
- props = {}
283
- attr_pattern = r'([a-zA-Z_][\w\-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|(\S+))'
284
- for attr_match in re.finditer(attr_pattern, attrs_str):
285
- attr_name = attr_match.group(1)
286
- attr_value = attr_match.group(2) or attr_match.group(
287
- 3) or attr_match.group(4) or ""
288
- camel_name = kebab_to_camel(attr_name)
289
- props[camel_name] = attr_value
290
-
291
- # Handle boolean attributes (no value)
292
- bool_attr_pattern = r'\s([a-zA-Z_][\w\-]*)(?=\s|/?>|$)(?!=)'
293
- for bool_match in re.finditer(bool_attr_pattern, attrs_str):
294
- attr_name = bool_match.group(1)
295
- if attr_name not in props:
296
- props[kebab_to_camel(attr_name)] = ""
297
-
298
- unique_id = f"{tag_name.lower()}_{uuid.uuid4().hex[:8]}"
299
-
300
- # 5. Add children if component accepts them
301
- if children.strip() and accepts_children(component_fn):
302
- props['children'] = wrap_scope(children, parent_scope)
303
-
304
- # 6. Render the component
305
- component_html = component_fn(**props)
306
-
307
- # 7. Recursive transform (component output may contain imports/components)
308
- if hasattr(component_fn, 'source_path') and component_fn.source_path:
309
- component_dir = os.path.dirname(component_fn.source_path)
310
- component_html = transform_components(
311
- component_html,
312
- parent_scope=parent_scope,
313
- base_dir=component_dir,
314
- _depth=_depth + 1
315
- )
316
-
317
- # 8. Add pp-component attribute to root element
318
- component_html = re.sub(
319
- r'^(\s*<[a-zA-Z][a-zA-Z0-9]*)',
320
- rf'\1 pp-component="{unique_id}"',
321
- str(component_html).strip(),
322
- count=1
323
- )
324
-
325
- # 9. Wrap in scope if needed
326
- if needs_parent_scope(props):
327
- component_html = wrap_scope(component_html, parent_scope)
328
-
329
- # 10. Replace original tag with transformed result
330
- html = html[:match.start()] + component_html + html[match.end():]
331
-
332
- # Restore script/style blocks exactly as they were
333
- html = _unmask_raw_blocks(html, raw_blocks)
334
- return html
File without changes
File without changes