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.
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/PKG-INFO +1 -1
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/component_decorator.py +49 -4
- caspian_utils-0.0.25/casp/components_compiler.py +523 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/layout.py +57 -1
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/caspian_utils.egg-info/PKG-INFO +1 -1
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/setup.py +1 -1
- caspian_utils-0.0.20/casp/components_compiler.py +0 -334
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/README.md +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/__init__.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/auth.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/cache_handler.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/caspian_config.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/html_attrs.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/loading.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/rpc.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/scripts_type.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/state_manager.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/string_helpers.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/syntax_compiler.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/tw.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/casp/validate.py +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/caspian_utils.egg-info/SOURCES.txt +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/caspian_utils.egg-info/dependency_links.txt +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/caspian_utils.egg-info/requires.txt +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/caspian_utils.egg-info/top_level.txt +0 -0
- {caspian_utils-0.0.20 → caspian_utils-0.0.25}/setup.cfg +0 -0
|
@@ -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
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|