caspian-utils 0.0.12__py3-none-any.whl
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.
- casp/__init__.py +0 -0
- casp/auth.py +537 -0
- casp/cache_handler.py +180 -0
- casp/caspian_config.py +441 -0
- casp/component_decorator.py +183 -0
- casp/components_compiler.py +293 -0
- casp/html_attrs.py +93 -0
- casp/layout.py +474 -0
- casp/loading.py +25 -0
- casp/rpc.py +230 -0
- casp/scripts_type.py +21 -0
- casp/state_manager.py +134 -0
- casp/string_helpers.py +18 -0
- casp/tw.py +31 -0
- casp/validate.py +747 -0
- caspian_utils-0.0.12.dist-info/METADATA +214 -0
- caspian_utils-0.0.12.dist-info/RECORD +19 -0
- caspian_utils-0.0.12.dist-info/WHEEL +5 -0
- caspian_utils-0.0.12.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from .layout import env, load_template_file
|
|
2
|
+
from .html_attrs import get_attributes
|
|
3
|
+
from typing import Callable, TypeVar, Dict, Optional, Any
|
|
4
|
+
from markupsafe import Markup
|
|
5
|
+
import inspect
|
|
6
|
+
import os
|
|
7
|
+
import importlib
|
|
8
|
+
import importlib.util
|
|
9
|
+
|
|
10
|
+
R = TypeVar('R')
|
|
11
|
+
|
|
12
|
+
_component_registry: Dict[str, 'Component'] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Component:
|
|
16
|
+
"""Wrapper class for PulsePoint components"""
|
|
17
|
+
_is_pp_component: bool = True
|
|
18
|
+
source_path: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
def __init__(self, fn: Callable[..., str], source_path: Optional[str] = None):
|
|
21
|
+
self.fn = fn
|
|
22
|
+
self.source_path = source_path
|
|
23
|
+
self.__name__ = fn.__name__
|
|
24
|
+
self.__doc__ = fn.__doc__
|
|
25
|
+
self.__signature__ = inspect.signature(fn)
|
|
26
|
+
_component_registry[fn.__name__] = self
|
|
27
|
+
|
|
28
|
+
def __call__(self, *args, **kwargs) -> str:
|
|
29
|
+
# Standardize React-like props to Pythonic names
|
|
30
|
+
if "className" in kwargs:
|
|
31
|
+
kwargs["class_name"] = kwargs.pop("className")
|
|
32
|
+
if "htmlFor" in kwargs:
|
|
33
|
+
kwargs["html_for"] = kwargs.pop("htmlFor")
|
|
34
|
+
return self.fn(*args, **kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def component(fn: Callable[..., str]) -> Component:
|
|
38
|
+
"""Decorator to mark and register a PulsePoint component"""
|
|
39
|
+
return Component(fn)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_component_from_path(path: str, component_name: str, base_dir: str = "") -> Component | None:
|
|
43
|
+
if path == '.' or path == './':
|
|
44
|
+
full_path = os.path.join(
|
|
45
|
+
base_dir, f"{component_name}.py") if base_dir else f"{component_name}.py"
|
|
46
|
+
elif path.startswith('./') or path.startswith('../'):
|
|
47
|
+
full_path = os.path.normpath(os.path.join(
|
|
48
|
+
base_dir, path)) if base_dir else os.path.normpath(path)
|
|
49
|
+
else:
|
|
50
|
+
full_path = path
|
|
51
|
+
|
|
52
|
+
if full_path.endswith('.py'):
|
|
53
|
+
file_path = full_path
|
|
54
|
+
elif os.path.isdir(full_path):
|
|
55
|
+
file_path = os.path.join(full_path, f"{component_name}.py")
|
|
56
|
+
else:
|
|
57
|
+
file_path = f"{full_path}.py" if not os.path.isdir(
|
|
58
|
+
full_path) else os.path.join(full_path, f"{component_name}.py")
|
|
59
|
+
|
|
60
|
+
if not os.path.exists(file_path):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
cwd = os.getcwd()
|
|
65
|
+
abs_file_path = os.path.abspath(file_path)
|
|
66
|
+
if abs_file_path.startswith(cwd):
|
|
67
|
+
rel_path = os.path.relpath(abs_file_path, cwd)
|
|
68
|
+
if not rel_path.startswith('..'):
|
|
69
|
+
module_name = os.path.splitext(
|
|
70
|
+
rel_path)[0].replace(os.sep, '.')
|
|
71
|
+
module = importlib.import_module(module_name)
|
|
72
|
+
comp = getattr(module, component_name, None)
|
|
73
|
+
if isinstance(comp, Component):
|
|
74
|
+
comp.source_path = abs_file_path
|
|
75
|
+
return comp
|
|
76
|
+
except (ImportError, ValueError, AttributeError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
unique_module_name = f"pp_component_{os.path.abspath(file_path).replace(os.sep, '_').replace('.', '_')}"
|
|
81
|
+
spec = importlib.util.spec_from_file_location(
|
|
82
|
+
unique_module_name, file_path)
|
|
83
|
+
if spec is None or spec.loader is None:
|
|
84
|
+
return None
|
|
85
|
+
module = importlib.util.module_from_spec(spec)
|
|
86
|
+
spec.loader.exec_module(module)
|
|
87
|
+
comp = getattr(module, component_name, None)
|
|
88
|
+
if isinstance(comp, Component):
|
|
89
|
+
comp.source_path = file_path
|
|
90
|
+
return comp
|
|
91
|
+
except Exception as e:
|
|
92
|
+
print(
|
|
93
|
+
f"Error loading component {component_name} from {file_path}: {e}")
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ====
|
|
100
|
+
# TEMPLATE RENDERING (Jinja2 powered)
|
|
101
|
+
# ====
|
|
102
|
+
|
|
103
|
+
def render_html(file_path: str, **kwargs) -> str:
|
|
104
|
+
"""
|
|
105
|
+
Load HTML template and render with Jinja2.
|
|
106
|
+
|
|
107
|
+
Behaves like React:
|
|
108
|
+
- [[ props ]] spreads attributes (class="...", id="...")
|
|
109
|
+
- [[ children ]] inserts the content (safe HTML)
|
|
110
|
+
- Supports passing __file__ to auto-discover sibling .html
|
|
111
|
+
"""
|
|
112
|
+
# Resolve file path relative to caller
|
|
113
|
+
frame = inspect.currentframe()
|
|
114
|
+
if frame and frame.f_back:
|
|
115
|
+
caller_file = frame.f_back.f_globals.get('__file__', '')
|
|
116
|
+
if caller_file:
|
|
117
|
+
base_dir = os.path.dirname(caller_file)
|
|
118
|
+
full_path = os.path.join(base_dir, file_path)
|
|
119
|
+
else:
|
|
120
|
+
full_path = file_path
|
|
121
|
+
else:
|
|
122
|
+
full_path = file_path
|
|
123
|
+
|
|
124
|
+
if full_path.endswith('.py'):
|
|
125
|
+
full_path = full_path[:-3] + '.html'
|
|
126
|
+
|
|
127
|
+
content = load_template_file(full_path)
|
|
128
|
+
|
|
129
|
+
# 1. EXTRACT CHILDREN
|
|
130
|
+
# We remove 'children' from props so it doesn't get rendered as an attribute.
|
|
131
|
+
children_content = kwargs.pop("children", "")
|
|
132
|
+
|
|
133
|
+
# 2. MARK CHILDREN AS SAFE
|
|
134
|
+
# If children is None or empty, use empty string. Otherwise mark as safe HTML.
|
|
135
|
+
safe_children = Markup(children_content) if children_content else ""
|
|
136
|
+
|
|
137
|
+
# 3. GENERATE ATTRIBUTES
|
|
138
|
+
# Pass the remaining kwargs to get_attributes.
|
|
139
|
+
# Mark the result as Markup so Jinja doesn't escape the quotes (class="foo").
|
|
140
|
+
props_string = Markup(get_attributes(kwargs))
|
|
141
|
+
|
|
142
|
+
# 4. RENDER
|
|
143
|
+
# We pass 'children' explicitly as the safe Markup object.
|
|
144
|
+
# We pass 'props' explicitly as the safe attribute string.
|
|
145
|
+
template = env.from_string(content)
|
|
146
|
+
return template.render(props=props_string, children=safe_children, **kwargs)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def render_component(py_file: str, template_name: str, context: Optional[Dict[str, Any]] = None) -> str:
|
|
150
|
+
"""
|
|
151
|
+
Render component template with context.
|
|
152
|
+
"""
|
|
153
|
+
context = context or {}
|
|
154
|
+
dir_path = os.path.dirname(os.path.abspath(py_file))
|
|
155
|
+
html_path = os.path.join(dir_path, template_name)
|
|
156
|
+
|
|
157
|
+
content = load_template_file(html_path)
|
|
158
|
+
|
|
159
|
+
# 1. Separate props from children
|
|
160
|
+
attrs = context.copy()
|
|
161
|
+
children_content = attrs.pop("children", "")
|
|
162
|
+
|
|
163
|
+
# 2. Mark safe
|
|
164
|
+
props_string = Markup(get_attributes(attrs))
|
|
165
|
+
safe_children = Markup(children_content) if children_content else ""
|
|
166
|
+
|
|
167
|
+
# 3. Render
|
|
168
|
+
template = env.from_string(content)
|
|
169
|
+
return template.render(props=props_string, children=safe_children, **context)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ====
|
|
173
|
+
# EXPORTS
|
|
174
|
+
# ====
|
|
175
|
+
|
|
176
|
+
__all__ = [
|
|
177
|
+
'Component',
|
|
178
|
+
'component',
|
|
179
|
+
'load_component_from_path',
|
|
180
|
+
'render_html',
|
|
181
|
+
'render_component',
|
|
182
|
+
'_component_registry',
|
|
183
|
+
]
|
|
@@ -0,0 +1,293 @@
|
|
|
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
|
|
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
|
+
attrs_original = {}
|
|
43
|
+
for attr_match in attr_pattern.finditer(full_tag):
|
|
44
|
+
attr_name = attr_match.group(1)
|
|
45
|
+
attrs_original[attr_name.lower()] = attr_name
|
|
46
|
+
|
|
47
|
+
placeholder = f"ppcomponent{component_name.lower()}"
|
|
48
|
+
if placeholder not in component_attrs_map:
|
|
49
|
+
component_attrs_map[placeholder] = []
|
|
50
|
+
component_attrs_map[placeholder].append(attrs_original)
|
|
51
|
+
|
|
52
|
+
return component_attrs_map
|
|
53
|
+
|
|
54
|
+
# ============================================================================
|
|
55
|
+
# IMPORT PARSING
|
|
56
|
+
# ============================================================================
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_imports(html: str) -> List[Dict]:
|
|
60
|
+
"""
|
|
61
|
+
Extract all import statements from HTML.
|
|
62
|
+
|
|
63
|
+
Supports:
|
|
64
|
+
<!-- @import Test from "./path" -->
|
|
65
|
+
<!-- @import Test from ./path --> (unquoted)
|
|
66
|
+
<!-- @import Test as MyTest from "./path" -->
|
|
67
|
+
<!-- @import { Button, Input } from "./path" -->
|
|
68
|
+
<!-- @import { Button as Btn, Input as Field } from "./path" -->
|
|
69
|
+
"""
|
|
70
|
+
imports = []
|
|
71
|
+
|
|
72
|
+
# Grouped imports: @import { A, B as C } from "path" or from path
|
|
73
|
+
grouped_pattern = r'<!--\s*@import\s*\{([^}]+)\}\s*from\s*["\']?([^"\'>\s]+)["\']?\s*-->'
|
|
74
|
+
for match in re.finditer(grouped_pattern, html):
|
|
75
|
+
members = match.group(1)
|
|
76
|
+
path = match.group(2)
|
|
77
|
+
|
|
78
|
+
for member in members.split(','):
|
|
79
|
+
member = member.strip()
|
|
80
|
+
if not member:
|
|
81
|
+
continue
|
|
82
|
+
if ' as ' in member:
|
|
83
|
+
parts = member.split(' as ')
|
|
84
|
+
original = parts[0].strip()
|
|
85
|
+
alias = parts[1].strip()
|
|
86
|
+
else:
|
|
87
|
+
original = member
|
|
88
|
+
alias = member
|
|
89
|
+
imports.append({
|
|
90
|
+
'original': original,
|
|
91
|
+
'alias': alias,
|
|
92
|
+
'path': path
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
# Single imports: @import Name from "path" OR @import Name as Alias from "path" (quotes optional)
|
|
96
|
+
single_pattern = r'<!--\s*@import\s+(?!\{)(\w+)(?:\s+as\s+(\w+))?\s+from\s*["\']?([^"\'>\s]+)["\']?\s*-->'
|
|
97
|
+
for match in re.finditer(single_pattern, html):
|
|
98
|
+
original = match.group(1)
|
|
99
|
+
alias = match.group(2) or original
|
|
100
|
+
path = match.group(3)
|
|
101
|
+
imports.append({
|
|
102
|
+
'original': original,
|
|
103
|
+
'alias': alias,
|
|
104
|
+
'path': path
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return imports
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def strip_imports(html: str) -> str:
|
|
111
|
+
"""Remove import comments from HTML"""
|
|
112
|
+
# Remove grouped imports (quoted or unquoted)
|
|
113
|
+
html = re.sub(
|
|
114
|
+
r'<!--\s*@import\s*\{[^}]+\}\s*from\s*["\']?[^"\'>\s]+["\']?\s*-->\n?', '', html)
|
|
115
|
+
# Remove single imports (quoted or unquoted)
|
|
116
|
+
html = re.sub(
|
|
117
|
+
r'<!--\s*@import\s+\w+(?:\s+as\s+\w+)?\s+from\s*["\']?[^"\'>\s]+["\']?\s*-->\n?', '', html)
|
|
118
|
+
return html
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_alias_map(imports: List[Dict], base_dir: str = "") -> Dict[str, Component]:
|
|
122
|
+
"""Build mapping from alias names to Component instances."""
|
|
123
|
+
alias_map = {}
|
|
124
|
+
|
|
125
|
+
for imp in imports:
|
|
126
|
+
original = imp['original']
|
|
127
|
+
alias = imp['alias']
|
|
128
|
+
path = imp['path']
|
|
129
|
+
|
|
130
|
+
component = load_component_from_path(path, original, base_dir)
|
|
131
|
+
if component:
|
|
132
|
+
alias_map[alias] = component
|
|
133
|
+
|
|
134
|
+
return alias_map
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def process_imports(html: str, base_dir: str = "") -> tuple[str, Dict[str, Component]]:
|
|
138
|
+
"""Process imports and return cleaned HTML with alias map."""
|
|
139
|
+
imports = extract_imports(html)
|
|
140
|
+
alias_map = build_alias_map(imports, base_dir)
|
|
141
|
+
cleaned_html = strip_imports(html)
|
|
142
|
+
return cleaned_html, alias_map
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ============================================================================
|
|
146
|
+
# COMPONENT HELPERS
|
|
147
|
+
# ============================================================================
|
|
148
|
+
|
|
149
|
+
def needs_parent_scope(attrs):
|
|
150
|
+
for key, value in attrs.items():
|
|
151
|
+
if key.lower().startswith('on'):
|
|
152
|
+
return True
|
|
153
|
+
if has_mustache(value):
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def wrap_scope(html, scope):
|
|
159
|
+
return f'<!-- pp-scope:{scope} -->{html}<!-- /pp-scope -->'
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def accepts_children(fn):
|
|
163
|
+
sig = fn.__signature__ if isinstance(
|
|
164
|
+
fn, Component) else inspect.signature(fn)
|
|
165
|
+
params = sig.parameters
|
|
166
|
+
return 'children' in params or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ============================================================================
|
|
170
|
+
# MAIN TRANSFORM
|
|
171
|
+
# ============================================================================
|
|
172
|
+
|
|
173
|
+
def transform_components(
|
|
174
|
+
html: str,
|
|
175
|
+
parent_scope: str = "app",
|
|
176
|
+
base_dir: str = "",
|
|
177
|
+
alias_map: Optional[Dict[str, Component]] = None,
|
|
178
|
+
_depth: int = 0
|
|
179
|
+
) -> str:
|
|
180
|
+
"""
|
|
181
|
+
Transform custom component tags. Components MUST be imported to be resolved.
|
|
182
|
+
Recursively processes the output of components to handle nested imports/components.
|
|
183
|
+
"""
|
|
184
|
+
if _depth > 50:
|
|
185
|
+
return html
|
|
186
|
+
|
|
187
|
+
# 1. Process imports for the current level
|
|
188
|
+
html, local_alias_map = process_imports(html, base_dir)
|
|
189
|
+
|
|
190
|
+
if alias_map:
|
|
191
|
+
merged_map = {**alias_map, **local_alias_map}
|
|
192
|
+
else:
|
|
193
|
+
merged_map = local_alias_map
|
|
194
|
+
|
|
195
|
+
# If no components are imported, we might still have standard HTML to return,
|
|
196
|
+
# but we can't transform custom tags without a map.
|
|
197
|
+
if not merged_map:
|
|
198
|
+
return html
|
|
199
|
+
|
|
200
|
+
# 2. Convert mustache attributes (e.g. someProp="{val}") to kebab-case (some-prop="{val}")
|
|
201
|
+
html = convert_mustache_attrs_to_kebab_raw(html)
|
|
202
|
+
|
|
203
|
+
# 3. Process components iteratively
|
|
204
|
+
max_iterations = 100
|
|
205
|
+
iteration = 0
|
|
206
|
+
|
|
207
|
+
while iteration < max_iterations:
|
|
208
|
+
iteration += 1
|
|
209
|
+
|
|
210
|
+
# Find a component tag (prefer innermost by finding one without nested components)
|
|
211
|
+
# This regex looks for <CapitalizedTag ... /> or <CapitalizedTag ...>...</CapitalizedTag>
|
|
212
|
+
component_pattern = r'<([A-Z][a-zA-Z0-9]*)([^>]*?)(?:/>|>([\s\S]*?)</\1>)'
|
|
213
|
+
|
|
214
|
+
match = None
|
|
215
|
+
for m in re.finditer(component_pattern, html):
|
|
216
|
+
tag_name = m.group(1)
|
|
217
|
+
# Only process if we have an import for this tag
|
|
218
|
+
if tag_name in merged_map:
|
|
219
|
+
inner_content = m.group(3) or ""
|
|
220
|
+
# Check if this has nested components - if so, skip for now (inner-first strategy)
|
|
221
|
+
if not re.search(r'<[A-Z][a-zA-Z0-9]*[^>]*(?:/>|>)', inner_content):
|
|
222
|
+
match = m
|
|
223
|
+
break
|
|
224
|
+
elif match is None:
|
|
225
|
+
match = m # Fallback to first match if all have nested components
|
|
226
|
+
|
|
227
|
+
if not match:
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
tag_name = match.group(1)
|
|
231
|
+
attrs_str = match.group(2) or ""
|
|
232
|
+
children = match.group(3) or ""
|
|
233
|
+
|
|
234
|
+
component_fn = merged_map.get(tag_name)
|
|
235
|
+
if not component_fn:
|
|
236
|
+
# Should not happen due to the check in the loop, but safety first
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
# 4. Parse attributes
|
|
240
|
+
props = {}
|
|
241
|
+
attr_pattern = r'([a-zA-Z_][\w\-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|(\S+))'
|
|
242
|
+
for attr_match in re.finditer(attr_pattern, attrs_str):
|
|
243
|
+
attr_name = attr_match.group(1)
|
|
244
|
+
attr_value = attr_match.group(2) or attr_match.group(
|
|
245
|
+
3) or attr_match.group(4) or ""
|
|
246
|
+
camel_name = kebab_to_camel(attr_name)
|
|
247
|
+
props[camel_name] = attr_value
|
|
248
|
+
|
|
249
|
+
# Handle boolean attributes (no value)
|
|
250
|
+
bool_attr_pattern = r'\s([a-zA-Z_][\w\-]*)(?=\s|/?>|$)(?!=)'
|
|
251
|
+
for bool_match in re.finditer(bool_attr_pattern, attrs_str):
|
|
252
|
+
attr_name = bool_match.group(1)
|
|
253
|
+
if attr_name not in props:
|
|
254
|
+
props[kebab_to_camel(attr_name)] = ""
|
|
255
|
+
|
|
256
|
+
unique_id = f"{tag_name.lower()}_{uuid.uuid4().hex[:8]}"
|
|
257
|
+
|
|
258
|
+
# 5. Add children if component accepts them
|
|
259
|
+
if children.strip() and accepts_children(component_fn):
|
|
260
|
+
props['children'] = wrap_scope(children, parent_scope)
|
|
261
|
+
|
|
262
|
+
# 6. Render the component (get the raw string output)
|
|
263
|
+
component_html = component_fn(**props)
|
|
264
|
+
|
|
265
|
+
# 7. RECURSIVE TRANSFORM [CRITICAL FIX]
|
|
266
|
+
# We must process the returned HTML to resolve imports and components
|
|
267
|
+
# inside the child component (e.g., EditTodo -> Button).
|
|
268
|
+
if hasattr(component_fn, 'source_path') and component_fn.source_path:
|
|
269
|
+
component_dir = os.path.dirname(component_fn.source_path)
|
|
270
|
+
component_html = transform_components(
|
|
271
|
+
component_html,
|
|
272
|
+
parent_scope=parent_scope, # Or a new scope if you want isolation
|
|
273
|
+
base_dir=component_dir, # Use the component's directory for relative imports
|
|
274
|
+
_depth=_depth + 1
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# 8. Add pp-component attribute to root element of the result
|
|
278
|
+
# (This marks it for PulsePoint reactivity in the DOM)
|
|
279
|
+
component_html = re.sub(
|
|
280
|
+
r'^(\s*<[a-zA-Z][a-zA-Z0-9]*)',
|
|
281
|
+
rf'\1 pp-component="{unique_id}"',
|
|
282
|
+
component_html.strip(),
|
|
283
|
+
count=1
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# 9. Wrap in scope if needed (for events/state binding)
|
|
287
|
+
if needs_parent_scope(props):
|
|
288
|
+
component_html = wrap_scope(component_html, parent_scope)
|
|
289
|
+
|
|
290
|
+
# 10. Replace the original tag in the HTML with the transformed result
|
|
291
|
+
html = html[:match.start()] + component_html + html[match.end():]
|
|
292
|
+
|
|
293
|
+
return html
|
casp/html_attrs.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from .string_helpers import camel_to_kebab
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
import html
|
|
5
|
+
from tailwind_merge import TailwindMerge
|
|
6
|
+
|
|
7
|
+
_twm = TailwindMerge()
|
|
8
|
+
|
|
9
|
+
# Map Python-safe names to HTML attributes
|
|
10
|
+
ALIAS_MAP = {
|
|
11
|
+
"class_name": "class",
|
|
12
|
+
"className": "class",
|
|
13
|
+
"html_for": "for",
|
|
14
|
+
"htmlFor": "for"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def merge_classes(*classes: Any) -> str:
|
|
19
|
+
parts: list[str] = []
|
|
20
|
+
for c in classes:
|
|
21
|
+
if not c:
|
|
22
|
+
continue
|
|
23
|
+
if isinstance(c, (list, tuple, set)):
|
|
24
|
+
s = " ".join(str(x) for x in c if x)
|
|
25
|
+
else:
|
|
26
|
+
s = str(c).strip()
|
|
27
|
+
if s:
|
|
28
|
+
parts.append(s)
|
|
29
|
+
return _twm.merge(*parts) if parts else ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _to_attr_value(v: Any) -> Optional[str]:
|
|
33
|
+
# XML-strict: booleans should have explicit values
|
|
34
|
+
if v is None:
|
|
35
|
+
return None
|
|
36
|
+
if isinstance(v, bool):
|
|
37
|
+
return "true" if v else None # omit false
|
|
38
|
+
if isinstance(v, (list, tuple, set)):
|
|
39
|
+
s = " ".join(str(x) for x in v if x)
|
|
40
|
+
return s if s else None
|
|
41
|
+
s = str(v)
|
|
42
|
+
return s if s != "" else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_attributes(props: Dict[str, Any], overrides: Optional[Dict[str, Any]] = None) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Build ONE attribute string.
|
|
48
|
+
- Aliases (class_name -> class) are always resolved.
|
|
49
|
+
- If the VALUE contains mustache syntax (e.g. "{var}"), the KEY is converted to kebab-case.
|
|
50
|
+
- Otherwise, the KEY maintains its original casing.
|
|
51
|
+
- Overrides take priority over props.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
final_attrs: Dict[str, str] = {}
|
|
55
|
+
|
|
56
|
+
def process_dict(d: Dict[str, Any]):
|
|
57
|
+
if not d:
|
|
58
|
+
return
|
|
59
|
+
for k, v in d.items():
|
|
60
|
+
if not k:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
# 1. Check Alias Map first (e.g., class_name -> class)
|
|
64
|
+
key_name = ALIAS_MAP.get(k, k)
|
|
65
|
+
|
|
66
|
+
# 2. Process value to string
|
|
67
|
+
val = _to_attr_value(v)
|
|
68
|
+
|
|
69
|
+
if val is not None:
|
|
70
|
+
# 3. Check for mustache syntax to decide on casing
|
|
71
|
+
# If value is a binding (has { and }), force kebab-case on the key
|
|
72
|
+
if "{" in val and "}" in val:
|
|
73
|
+
final_key = camel_to_kebab(key_name)
|
|
74
|
+
else:
|
|
75
|
+
# Otherwise, keep original casing (e.g. standard attributes)
|
|
76
|
+
final_key = key_name
|
|
77
|
+
|
|
78
|
+
final_attrs[final_key] = val
|
|
79
|
+
|
|
80
|
+
# 1. Process base props
|
|
81
|
+
process_dict(props)
|
|
82
|
+
|
|
83
|
+
# 2. Process overrides (these will overwrite keys from step 1)
|
|
84
|
+
if overrides:
|
|
85
|
+
process_dict(overrides)
|
|
86
|
+
|
|
87
|
+
# 3. Build string
|
|
88
|
+
parts: list[str] = []
|
|
89
|
+
# Sorting keys is optional but helpful for consistent testing/output
|
|
90
|
+
for k, v in final_attrs.items():
|
|
91
|
+
parts.append(f'{k}="{html.escape(v, quote=True)}"')
|
|
92
|
+
|
|
93
|
+
return " ".join(parts)
|