tw-framework 0.2.3__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.
- tw_framework/__init__.py +13 -0
- tw_framework/advanced_diagnostics.py +25 -0
- tw_framework/asset_optimizer.py +30 -0
- tw_framework/ast_nodes.py +283 -0
- tw_framework/build.py +7 -0
- tw_framework/build_performance.py +26 -0
- tw_framework/build_report.py +84 -0
- tw_framework/cli.py +1258 -0
- tw_framework/code_splitting.py +76 -0
- tw_framework/common.py +22 -0
- tw_framework/compiler.py +4817 -0
- tw_framework/compiler_stats.py +51 -0
- tw_framework/dead_code.py +44 -0
- tw_framework/dependency_graph.py +91 -0
- tw_framework/deploy.py +7 -0
- tw_framework/dev.py +9 -0
- tw_framework/diagnostics.py +98 -0
- tw_framework/dynamic_imports.py +51 -0
- tw_framework/error_formatter.py +53 -0
- tw_framework/framework.py +3216 -0
- tw_framework/hydration.py +129 -0
- tw_framework/incremental_cache.py +53 -0
- tw_framework/interpreter.py +42 -0
- tw_framework/ir.py +171 -0
- tw_framework/lexer.py +52 -0
- tw_framework/lowering.py +68 -0
- tw_framework/parser.py +167 -0
- tw_framework/partial_rebuild.py +24 -0
- tw_framework/performance_analyzer.py +37 -0
- tw_framework/plugin_runtime.py +196 -0
- tw_framework/production_optimizer.py +82 -0
- tw_framework/reactivity.py +349 -0
- tw_framework/render_css.py +22 -0
- tw_framework/render_html.py +259 -0
- tw_framework/route_optimizer.py +40 -0
- tw_framework/router.py +7 -0
- tw_framework/runtime_model.py +23 -0
- tw_framework/runtime_values.py +31 -0
- tw_framework/semantic.py +162 -0
- tw_framework/server.py +532 -0
- tw_framework/signature.py +51 -0
- tw_framework/static_dynamic_auto.py +32 -0
- tw_framework/streaming.py +135 -0
- tw_framework/test_build_cli_pytest.py +1078 -0
- tw_framework/tree_shaking.py +48 -0
- tw_framework/twm_parser.py +274 -0
- tw_framework-0.2.3.dist-info/METADATA +8 -0
- tw_framework-0.2.3.dist-info/RECORD +51 -0
- tw_framework-0.2.3.dist-info/WHEEL +5 -0
- tw_framework-0.2.3.dist-info/entry_points.txt +2 -0
- tw_framework-0.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _legacy():
|
|
5
|
+
from . import compiler
|
|
6
|
+
|
|
7
|
+
return compiler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_stylesheet(text: str):
|
|
11
|
+
compiler = _legacy()
|
|
12
|
+
return compiler.build_tss_ast_from_text(text)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_stylesheet(text: str, context=None, minify: bool = False) -> str:
|
|
16
|
+
compiler = _legacy()
|
|
17
|
+
sheet = parse_stylesheet(text)
|
|
18
|
+
css = compiler.render_css(sheet, context=context or {})
|
|
19
|
+
return compiler.minify_css_content(css) if minify else css
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["parse_stylesheet", "render_stylesheet"]
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import Dict, Generator, Iterable, List, Optional
|
|
7
|
+
|
|
8
|
+
from .ir import IRComponent, IRElement, IRFor, IRIf, IRLet, IRProgram, IRScript, IRText
|
|
9
|
+
from .runtime_values import RuntimeEnvironment
|
|
10
|
+
from .signature import build_signature_banner, build_signature_meta_tag, compute_tw_signature
|
|
11
|
+
from .hydration import wrap_interactive_nodes
|
|
12
|
+
from .streaming import render_program_streaming
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _legacy():
|
|
18
|
+
from . import compiler
|
|
19
|
+
|
|
20
|
+
return compiler
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
VOID_TAGS = {"br", "hr", "img", "input", "meta", "link"}
|
|
24
|
+
|
|
25
|
+
_COMPONENT_PROGRAM_CACHE: Dict[str, IRProgram] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _interpolate(value, context):
|
|
29
|
+
compiler = _legacy()
|
|
30
|
+
if value is None:
|
|
31
|
+
return ""
|
|
32
|
+
if not isinstance(value, str):
|
|
33
|
+
return value
|
|
34
|
+
return compiler.interpolate(value, context)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _evaluate(expr: str, context):
|
|
38
|
+
compiler = _legacy()
|
|
39
|
+
return compiler.evaluate_expression(expr, context)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_runtime_context(program, context: Optional[Dict] = None) -> Dict:
|
|
43
|
+
compiler = _legacy()
|
|
44
|
+
runtime_context: Dict = {}
|
|
45
|
+
runtime_context.update(dict(program.lets))
|
|
46
|
+
runtime_context.update(dict(program.state))
|
|
47
|
+
if getattr(program, "legacy_page", None) is not None and getattr(program, "source_path", ""):
|
|
48
|
+
try:
|
|
49
|
+
base_context = compiler.create_base_context(program.legacy_page, program.source_path)
|
|
50
|
+
runtime_context.update(base_context)
|
|
51
|
+
except Exception:
|
|
52
|
+
logger.exception("Failed to create base runtime context for %s", getattr(program, "source_path", ""))
|
|
53
|
+
runtime_context.update(dict(context or {}))
|
|
54
|
+
return runtime_context
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _render_attrs(attrs: Iterable[Dict], env: RuntimeEnvironment) -> str:
|
|
58
|
+
parts: List[str] = []
|
|
59
|
+
context = env.to_context()
|
|
60
|
+
for attr in attrs:
|
|
61
|
+
value = _interpolate(attr["value"], context)
|
|
62
|
+
if value is True:
|
|
63
|
+
parts.append(attr["name"])
|
|
64
|
+
continue
|
|
65
|
+
if value in {False, None}:
|
|
66
|
+
continue
|
|
67
|
+
parts.append(f'{attr["name"]}="{html.escape(str(value), quote=True)}"')
|
|
68
|
+
return f" {' '.join(parts)}" if parts else ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _render_style(styles: Iterable[Dict], env: RuntimeEnvironment) -> str:
|
|
72
|
+
resolved = []
|
|
73
|
+
context = env.to_context()
|
|
74
|
+
for item in styles:
|
|
75
|
+
value = _interpolate(item["value"], context)
|
|
76
|
+
resolved.append(f'{item["name"]}: {value}')
|
|
77
|
+
return f' style="{html.escape("; ".join(resolved), quote=True)}"' if resolved else ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_component_ir(name: str) -> Optional[IRProgram]:
|
|
81
|
+
compiler = _legacy()
|
|
82
|
+
try:
|
|
83
|
+
path = compiler.resolve_component_path(name)
|
|
84
|
+
except Exception:
|
|
85
|
+
logger.exception("Failed to resolve component path for `%s`", name)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
if not path or not os.path.exists(path):
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
cached = _COMPONENT_PROGRAM_CACHE.get(path)
|
|
92
|
+
if cached is not None:
|
|
93
|
+
return cached
|
|
94
|
+
|
|
95
|
+
from .lowering import lower_program
|
|
96
|
+
from .parser import parse_file
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
program = parse_file(path)
|
|
100
|
+
ir_program = lower_program(program)
|
|
101
|
+
except Exception:
|
|
102
|
+
logger.exception("Failed to parse/lower component `%s` at %s", name, path)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
_COMPONENT_PROGRAM_CACHE[path] = ir_program
|
|
106
|
+
return ir_program
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_node(node, env: RuntimeEnvironment) -> str:
|
|
110
|
+
context = env.to_context()
|
|
111
|
+
if isinstance(node, IRText):
|
|
112
|
+
return html.escape(str(_interpolate(node.value, context)))
|
|
113
|
+
if isinstance(node, IRLet):
|
|
114
|
+
env.set(node.name, _interpolate(node.value, context))
|
|
115
|
+
return ""
|
|
116
|
+
if isinstance(node, IRIf):
|
|
117
|
+
branch = node.children if _evaluate(node.condition, context) else node.else_children
|
|
118
|
+
return "".join(render_node(child, env.child()) for child in branch)
|
|
119
|
+
if isinstance(node, IRFor):
|
|
120
|
+
rendered = []
|
|
121
|
+
items = _evaluate(node.iterable, context)
|
|
122
|
+
for item in items or []:
|
|
123
|
+
loop_env = env.child({node.var_name: item})
|
|
124
|
+
rendered.append("".join(render_node(child, loop_env) for child in node.children))
|
|
125
|
+
return "".join(rendered)
|
|
126
|
+
if isinstance(node, IRScript):
|
|
127
|
+
return f"<script>{node.raw_js}</script>"
|
|
128
|
+
if isinstance(node, IRComponent):
|
|
129
|
+
props = {prop["name"]: _interpolate(prop["value"], context) for prop in node.props}
|
|
130
|
+
|
|
131
|
+
stack = context.get("__tw_component_stack__", ())
|
|
132
|
+
if node.name in stack:
|
|
133
|
+
chain = " -> ".join(stack + (node.name,))
|
|
134
|
+
logger.error("Circular component reference detected: %s", chain)
|
|
135
|
+
return (
|
|
136
|
+
f'<div data-tw-component-error="circular" data-tw-component="{html.escape(node.name, quote=True)}">'
|
|
137
|
+
f"Circular component reference: {html.escape(chain)}"
|
|
138
|
+
f"</div>"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
component_ir = _load_component_ir(node.name)
|
|
142
|
+
if component_ir is None:
|
|
143
|
+
children_html = "".join(render_node(child, env.child()) for child in node.children)
|
|
144
|
+
props_html = "".join(
|
|
145
|
+
f'<li><strong>{html.escape(str(key))}</strong>: {html.escape(str(value))}</li>'
|
|
146
|
+
for key, value in props.items()
|
|
147
|
+
)
|
|
148
|
+
logger.warning("Component `%s` could not be resolved; rendering placeholder.", node.name)
|
|
149
|
+
return (
|
|
150
|
+
f'<div data-tw-component-error="not-found" data-tw-component="{html.escape(node.name, quote=True)}">'
|
|
151
|
+
f"<div>Component not found: {html.escape(node.name)}</div>"
|
|
152
|
+
f"<ul>{props_html}</ul>{children_html}</div>"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
component_env = RuntimeEnvironment(
|
|
156
|
+
values={
|
|
157
|
+
**dict(component_ir.lets),
|
|
158
|
+
**dict(component_ir.state),
|
|
159
|
+
**props,
|
|
160
|
+
"__tw_component_stack__": stack + (node.name,),
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
return "".join(render_node(child, component_env) for child in component_ir.body)
|
|
164
|
+
if isinstance(node, IRElement):
|
|
165
|
+
attrs = _render_attrs(node.attrs, env)
|
|
166
|
+
style_attr = _render_style(node.styles, env)
|
|
167
|
+
event_attrs = _render_attrs(node.events, env)
|
|
168
|
+
text = html.escape(str(_interpolate(node.text, context))) if node.text is not None else ""
|
|
169
|
+
children = "".join(render_node(child, env.child()) for child in node.children)
|
|
170
|
+
if node.tag in VOID_TAGS:
|
|
171
|
+
return f"<{node.tag}{attrs}{style_attr}{event_attrs}>"
|
|
172
|
+
return f"<{node.tag}{attrs}{style_attr}{event_attrs}>{text}{children}</{node.tag}>"
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render_program(program: IRProgram, context: Optional[Dict] = None) -> str:
|
|
177
|
+
env = RuntimeEnvironment(values={**dict(program.lets), **dict(program.state), **dict(context or {})})
|
|
178
|
+
body = "".join(render_node(node, env) for node in program.body)
|
|
179
|
+
title = html.escape(str(program.meta.get("title") or "TW Program"))
|
|
180
|
+
|
|
181
|
+
signature = compute_tw_signature(program)
|
|
182
|
+
banner = build_signature_banner(signature, title=title)
|
|
183
|
+
meta_tags = build_signature_meta_tag(signature)
|
|
184
|
+
|
|
185
|
+
html_output = (
|
|
186
|
+
"<!DOCTYPE html>"
|
|
187
|
+
f"{banner}"
|
|
188
|
+
"<html>"
|
|
189
|
+
"<head>"
|
|
190
|
+
f"<title>{title}</title>"
|
|
191
|
+
f"{meta_tags}"
|
|
192
|
+
"</head>"
|
|
193
|
+
f"<body>{body}</body>"
|
|
194
|
+
"</html>"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Apply hydration markers for interactive components
|
|
198
|
+
html_output = wrap_interactive_nodes(html_output, program, context=context)
|
|
199
|
+
return html_output
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _inject_tw_signature(html_doc: str, banner: str, meta_tag: str) -> str:
|
|
203
|
+
"""
|
|
204
|
+
Legacy renderer सीधे HTML string return करता है, IR tree नहीं, इसलिए
|
|
205
|
+
यहां हम एक best-effort post-processing करते हैं: document के सबसे ऊपर
|
|
206
|
+
एक comment banner डालते हैं, और अगर `<head>` tag मिल जाए तो उसके ठीक
|
|
207
|
+
बाद <meta> tags भी डाल देते हैं। अगर `<head>` न मिले, तो signature फिर
|
|
208
|
+
भी silently गायब नहीं होगी — banner के बाद meta tags जोड़ दी जाती हैं।
|
|
209
|
+
"""
|
|
210
|
+
lowered = html_doc.lower()
|
|
211
|
+
head_idx = lowered.find("<head>")
|
|
212
|
+
if head_idx != -1:
|
|
213
|
+
insert_at = head_idx + len("<head>")
|
|
214
|
+
html_doc = html_doc[:insert_at] + "\n" + meta_tag + html_doc[insert_at:]
|
|
215
|
+
return banner + "\n" + html_doc
|
|
216
|
+
return banner + "\n" + meta_tag + "\n" + html_doc
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def render_streaming(ir_program: IRProgram, *, page_program=None, context: Optional[Dict] = None, css_href: Optional[str] = None) -> Generator[str, None, None]:
|
|
220
|
+
"""Yield HTML chunks for streaming SSR."""
|
|
221
|
+
from .streaming import render_program_streaming
|
|
222
|
+
yield from render_program_streaming(ir_program, context=context)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def render_program_document(ir_program: IRProgram, *, page_program=None, context: Optional[Dict] = None, css_href: Optional[str] = None, debug: bool = False) -> str:
|
|
226
|
+
compiler = _legacy()
|
|
227
|
+
merged_context = build_runtime_context(page_program, context=context) if page_program is not None else dict(context or {})
|
|
228
|
+
|
|
229
|
+
# Check if streaming is enabled via config
|
|
230
|
+
config = merged_context.get("config", {})
|
|
231
|
+
use_streaming = bool(config.get("streaming", False))
|
|
232
|
+
|
|
233
|
+
if use_streaming:
|
|
234
|
+
chunks = list(render_program_streaming(ir_program, context=merged_context))
|
|
235
|
+
html_output = "".join(chunks)
|
|
236
|
+
# Apply hydration markers
|
|
237
|
+
html_output = wrap_interactive_nodes(html_output, ir_program, context=merged_context)
|
|
238
|
+
return html_output
|
|
239
|
+
|
|
240
|
+
if page_program is not None and getattr(page_program, "legacy_page", None) is not None:
|
|
241
|
+
resolved_css = css_href
|
|
242
|
+
if resolved_css is None:
|
|
243
|
+
try:
|
|
244
|
+
resolved_css, _ = compiler.read_global_stylesheet()
|
|
245
|
+
except Exception:
|
|
246
|
+
logger.exception("Failed to read global stylesheet")
|
|
247
|
+
resolved_css = ""
|
|
248
|
+
raw_html = compiler.render_html(page_program.legacy_page, merged_context, resolved_css or "")
|
|
249
|
+
|
|
250
|
+
signature = compute_tw_signature(page_program)
|
|
251
|
+
title = str(getattr(page_program.meta, "title", "") or "")
|
|
252
|
+
banner = build_signature_banner(signature, title=title)
|
|
253
|
+
meta_tag = build_signature_meta_tag(signature)
|
|
254
|
+
return _inject_tw_signature(raw_html, banner, meta_tag)
|
|
255
|
+
|
|
256
|
+
return render_program(ir_program, context=merged_context)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
__all__ = ["build_runtime_context", "render_node", "render_program", "render_program_document"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Automatic route optimization for TW Framework.
|
|
3
|
+
|
|
4
|
+
Detects duplicate routes, unreachable routes, route conflicts, and shadowed routes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Dict, List
|
|
9
|
+
|
|
10
|
+
from . import compiler
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def optimize_routes(project_root: str) -> Dict[str, List[str]]:
|
|
16
|
+
"""Analyze routes and return optimization suggestions."""
|
|
17
|
+
issues = {
|
|
18
|
+
"duplicate_routes": [],
|
|
19
|
+
"unreachable_routes": [],
|
|
20
|
+
"route_conflicts": [],
|
|
21
|
+
"shadowed_routes": [],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
pages = compiler.discover_pages()
|
|
25
|
+
route_map: Dict[str, List[str]] = {}
|
|
26
|
+
for page in pages:
|
|
27
|
+
route = compiler.route_path_from_page_info(page)
|
|
28
|
+
if route not in route_map:
|
|
29
|
+
route_map[route] = []
|
|
30
|
+
route_map[route].append(page["path"])
|
|
31
|
+
|
|
32
|
+
for route, paths in route_map.items():
|
|
33
|
+
if len(paths) > 1:
|
|
34
|
+
issues["duplicate_routes"].append({"route": route, "paths": paths})
|
|
35
|
+
|
|
36
|
+
# TODO: Add unreachable, conflict, and shadow detection
|
|
37
|
+
return issues
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["optimize_routes"]
|
tw_framework/router.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class CompilerArtifacts:
|
|
9
|
+
source_path: str = ""
|
|
10
|
+
tokens: List[Dict[str, Any]] = field(default_factory=list)
|
|
11
|
+
ast: Optional[Dict[str, Any]] = None
|
|
12
|
+
diagnostics: List[Dict[str, Any]] = field(default_factory=list)
|
|
13
|
+
ir: Optional[Dict[str, Any]] = None
|
|
14
|
+
html: Optional[str] = None
|
|
15
|
+
program: Any = None
|
|
16
|
+
runtime_context: Dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
dependencies: List[str] = field(default_factory=list)
|
|
18
|
+
route_path: str = "/"
|
|
19
|
+
pipeline: str = "modular"
|
|
20
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = ["CompilerArtifacts"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class RuntimeEnvironment:
|
|
9
|
+
values: Dict[str, Any] = field(default_factory=dict)
|
|
10
|
+
parent: Optional["RuntimeEnvironment"] = None
|
|
11
|
+
|
|
12
|
+
def get(self, name: str, default=None):
|
|
13
|
+
if name in self.values:
|
|
14
|
+
return self.values[name]
|
|
15
|
+
if self.parent is not None:
|
|
16
|
+
return self.parent.get(name, default)
|
|
17
|
+
return default
|
|
18
|
+
|
|
19
|
+
def set(self, name: str, value: Any):
|
|
20
|
+
self.values[name] = value
|
|
21
|
+
|
|
22
|
+
def to_context(self) -> Dict[str, Any]:
|
|
23
|
+
data = self.parent.to_context() if self.parent is not None else {}
|
|
24
|
+
data.update(self.values)
|
|
25
|
+
return data
|
|
26
|
+
|
|
27
|
+
def child(self, extra: Optional[Dict[str, Any]] = None) -> "RuntimeEnvironment":
|
|
28
|
+
return RuntimeEnvironment(values=dict(extra or {}), parent=self)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = ["RuntimeEnvironment"]
|
tw_framework/semantic.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
from .ast_nodes import ComponentNode, ElementNode, ForNode, IfNode, LetNode, Program
|
|
7
|
+
from .diagnostics import Diagnostic, DiagnosticBag
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _legacy():
|
|
13
|
+
from . import compiler
|
|
14
|
+
|
|
15
|
+
return compiler
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SemanticAnalyzer:
|
|
19
|
+
def _check_expression(self, expr, scope, diagnostics: DiagnosticBag, source_path: str, label: str):
|
|
20
|
+
compiler = _legacy()
|
|
21
|
+
try:
|
|
22
|
+
names = compiler.collect_expression_names(expr)
|
|
23
|
+
except Exception:
|
|
24
|
+
logger.exception("Failed to collect expression names (%s) in %s: %r", source_path, label, expr)
|
|
25
|
+
names = set()
|
|
26
|
+
for name in sorted(names):
|
|
27
|
+
if name not in scope:
|
|
28
|
+
diagnostics.add(
|
|
29
|
+
Diagnostic(
|
|
30
|
+
severity="error",
|
|
31
|
+
code="TW2101",
|
|
32
|
+
message=f"Unknown name `{name}` in {label}.",
|
|
33
|
+
file_path=source_path,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _check_interpolations(self, text, scope, diagnostics: DiagnosticBag, source_path: str, label: str):
|
|
38
|
+
compiler = _legacy()
|
|
39
|
+
if not isinstance(text, str):
|
|
40
|
+
return
|
|
41
|
+
for expr in compiler.extract_placeholder_expressions(text):
|
|
42
|
+
self._check_expression(expr, scope, diagnostics, source_path, label)
|
|
43
|
+
|
|
44
|
+
def _walk_nodes(self, nodes, scope, diagnostics: DiagnosticBag, source_path: str):
|
|
45
|
+
next_scope = dict(scope)
|
|
46
|
+
for node in nodes:
|
|
47
|
+
if isinstance(node, LetNode):
|
|
48
|
+
next_scope[node.name] = node.value
|
|
49
|
+
self._check_interpolations(node.value, next_scope, diagnostics, source_path, f"`let {node.name}`")
|
|
50
|
+
continue
|
|
51
|
+
if isinstance(node, IfNode):
|
|
52
|
+
self._check_expression(node.condition, next_scope, diagnostics, source_path, "`if` condition")
|
|
53
|
+
self._walk_nodes(node.children, dict(next_scope), diagnostics, source_path)
|
|
54
|
+
self._walk_nodes(node.else_children, dict(next_scope), diagnostics, source_path)
|
|
55
|
+
continue
|
|
56
|
+
if isinstance(node, ForNode):
|
|
57
|
+
self._check_expression(node.iterable, next_scope, diagnostics, source_path, "`for` iterable")
|
|
58
|
+
child_scope = dict(next_scope)
|
|
59
|
+
child_scope[node.var_name] = True
|
|
60
|
+
self._walk_nodes(node.children, child_scope, diagnostics, source_path)
|
|
61
|
+
continue
|
|
62
|
+
if isinstance(node, ElementNode):
|
|
63
|
+
self._check_interpolations(node.text, next_scope, diagnostics, source_path, f"`{node.tag}` text")
|
|
64
|
+
for attr in node.attrs + node.styles + node.events:
|
|
65
|
+
self._check_interpolations(attr.value, next_scope, diagnostics, source_path, f"`{node.tag}` `{attr.name}`")
|
|
66
|
+
for value in node.router.values():
|
|
67
|
+
self._check_interpolations(value, next_scope, diagnostics, source_path, f"`{node.tag}` router")
|
|
68
|
+
self._walk_nodes(node.children, dict(next_scope), diagnostics, source_path)
|
|
69
|
+
continue
|
|
70
|
+
if isinstance(node, ComponentNode):
|
|
71
|
+
for prop in node.props:
|
|
72
|
+
self._check_interpolations(prop.value, next_scope, diagnostics, source_path, f"`{node.name}` prop `{prop.name}`")
|
|
73
|
+
self._walk_nodes(node.children, dict(next_scope), diagnostics, source_path)
|
|
74
|
+
|
|
75
|
+
def _check_page_meta(self, program: Program, diagnostics: DiagnosticBag, source_path: str):
|
|
76
|
+
meta = program.meta
|
|
77
|
+
|
|
78
|
+
valid_render_modes = {"static", "server", "edge"}
|
|
79
|
+
if meta.render_mode not in valid_render_modes:
|
|
80
|
+
diagnostics.add(
|
|
81
|
+
Diagnostic(
|
|
82
|
+
severity="error",
|
|
83
|
+
code="TW2201",
|
|
84
|
+
message=f'Invalid `render` mode "{meta.render_mode}". Use one of: static, server, edge.',
|
|
85
|
+
file_path=source_path,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if meta.render_mode == "static" and meta.revalidate is not None:
|
|
90
|
+
diagnostics.add(
|
|
91
|
+
Diagnostic(
|
|
92
|
+
severity="warning",
|
|
93
|
+
code="TW2202",
|
|
94
|
+
message="`revalidate` has no effect on `render static` pages. "
|
|
95
|
+
"Use `render server` or `render edge` for ISR-style revalidation.",
|
|
96
|
+
file_path=source_path,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if meta.revalidate is not None and meta.revalidate < 0:
|
|
101
|
+
diagnostics.add(
|
|
102
|
+
Diagnostic(
|
|
103
|
+
severity="error",
|
|
104
|
+
code="TW2203",
|
|
105
|
+
message=f"`revalidate` must be a non-negative number, got {meta.revalidate}.",
|
|
106
|
+
file_path=source_path,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if meta.redirect_to and meta.rewrite_to:
|
|
111
|
+
diagnostics.add(
|
|
112
|
+
Diagnostic(
|
|
113
|
+
severity="error",
|
|
114
|
+
code="TW2204",
|
|
115
|
+
message="A page cannot define both `redirect` and `rewrite` at the same time.",
|
|
116
|
+
file_path=source_path,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def analyze(self, program: Program, context: Optional[Dict] = None) -> DiagnosticBag:
|
|
121
|
+
diagnostics = DiagnosticBag()
|
|
122
|
+
compiler = _legacy()
|
|
123
|
+
|
|
124
|
+
if program.legacy_page and program.source_path:
|
|
125
|
+
try:
|
|
126
|
+
analysis_context = context or compiler.create_base_context(program.legacy_page, program.source_path)
|
|
127
|
+
except Exception:
|
|
128
|
+
logger.exception("Failed to create base context for %s", program.source_path)
|
|
129
|
+
analysis_context = context or {}
|
|
130
|
+
legacy_items = compiler.analyze_page_semantics(
|
|
131
|
+
program.legacy_page,
|
|
132
|
+
analysis_context,
|
|
133
|
+
program.source_path,
|
|
134
|
+
)
|
|
135
|
+
diagnostics.extend(Diagnostic.from_legacy(item) for item in legacy_items)
|
|
136
|
+
|
|
137
|
+
if not program.meta.title:
|
|
138
|
+
diagnostics.add(
|
|
139
|
+
Diagnostic(
|
|
140
|
+
severity="warning",
|
|
141
|
+
code="TW2001",
|
|
142
|
+
message='Page title is empty. Consider setting `page { title "..." }`.',
|
|
143
|
+
file_path=program.source_path,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
self._check_page_meta(program, diagnostics, program.source_path)
|
|
148
|
+
|
|
149
|
+
scope = {}
|
|
150
|
+
scope.update(program.lets)
|
|
151
|
+
scope.update(program.state)
|
|
152
|
+
scope.update(context or {})
|
|
153
|
+
self._walk_nodes(program.body, scope, diagnostics, program.source_path)
|
|
154
|
+
|
|
155
|
+
return diagnostics
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def analyze_program(program: Program, context: Optional[Dict] = None) -> DiagnosticBag:
|
|
159
|
+
return SemanticAnalyzer().analyze(program, context=context)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
__all__ = ["SemanticAnalyzer", "analyze_program"]
|