quantum-framework 0.9.0__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.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +417 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +527 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +547 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2611 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +148 -0
- quantum/core/expressions.py +591 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_fetching/src/__init__.py +21 -0
- quantum/core/features/data_fetching/src/ast_node.py +312 -0
- quantum/core/features/data_fetching/src/desktop_adapter.py +351 -0
- quantum/core/features/data_fetching/src/html_adapter.py +474 -0
- quantum/core/features/data_fetching/src/parser.py +225 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +291 -0
- quantum/core/features/data_import/src/runtime.py +538 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +146 -0
- quantum/core/features/invocation/src/runtime.py +327 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/database_service.py +322 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +228 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1854 -0
- quantum/core/features/ui_engine/src/parser.py +1106 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1235 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +68 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +89 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +217 -0
- quantum/core/parsers/data/invoke_parser.py +116 -0
- quantum/core/parsers/data/query_parser.py +188 -0
- quantum/core/parsers/data/transaction_parser.py +77 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +54 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +137 -0
- quantum/core/parsers/functions/param_parser.py +66 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/persistence/__init__.py +9 -0
- quantum/core/parsers/persistence/persist_parser.py +64 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +82 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +28 -0
- quantum/runtime/action_handler.py +443 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/api_server.py +86 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +990 -0
- quantum/runtime/component_composer.py +319 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/database_service.py +598 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +286 -0
- quantum/runtime/executor_registry.py +171 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +262 -0
- quantum/runtime/executors/control_flow/__init__.py +11 -0
- quantum/runtime/executors/control_flow/if_executor.py +93 -0
- quantum/runtime/executors/control_flow/loop_executor.py +307 -0
- quantum/runtime/executors/control_flow/set_executor.py +412 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +145 -0
- quantum/runtime/executors/data/invoke_executor.py +176 -0
- quantum/runtime/executors/data/query_executor.py +256 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +500 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/logging_setup.py +140 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +184 -0
- quantum/runtime/pypy_compat.py +315 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +733 -0
- quantum/runtime/service_container.py +444 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +188 -0
- quantum/runtime/ui_desktop_adapter.py +1730 -0
- quantum/runtime/ui_desktop_templates.py +307 -0
- quantum/runtime/ui_html_adapter.py +2691 -0
- quantum/runtime/ui_html_templates.py +2297 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +1766 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/utils/__init__.py +1 -0
- quantum_framework-0.9.0.dist-info/METADATA +244 -0
- quantum_framework-0.9.0.dist-info/RECORD +262 -0
- quantum_framework-0.9.0.dist-info/WHEEL +5 -0
- quantum_framework-0.9.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.9.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quantum HTML Renderer
|
|
3
|
+
|
|
4
|
+
Converts executed AST to HTML string output.
|
|
5
|
+
Handles databinding, loops, conditionals, and HTML passthrough.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, List
|
|
15
|
+
|
|
16
|
+
# Fix imports
|
|
17
|
+
|
|
18
|
+
from quantum.core.ast_nodes import (
|
|
19
|
+
QuantumNode, ComponentNode, HTMLNode, TextNode, DocTypeNode,
|
|
20
|
+
CommentNode, QueryNode, ComponentCallNode, ImportNode
|
|
21
|
+
)
|
|
22
|
+
from quantum.core.expression_diagnostics import is_absent_scope, report_unresolved
|
|
23
|
+
from quantum.core.expressions import (
|
|
24
|
+
ExpressionEvaluator, ExpressionError, is_regex_quantifier,
|
|
25
|
+
)
|
|
26
|
+
from quantum.core.features.conditionals.src.ast_node import IfNode
|
|
27
|
+
from quantum.core.features.loops.src.ast_node import LoopNode
|
|
28
|
+
from quantum.core.features.state_management.src.ast_node import SetNode
|
|
29
|
+
from quantum.runtime.execution_context import ExecutionContext
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger('quantum.renderer')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HTMLRenderer:
|
|
35
|
+
"""
|
|
36
|
+
Renders Quantum AST to HTML string.
|
|
37
|
+
|
|
38
|
+
Phase 1: Server-side rendering only
|
|
39
|
+
Phase 3: Will support hydration markers for client-side interactivity
|
|
40
|
+
|
|
41
|
+
Handles:
|
|
42
|
+
- HTML elements passthrough
|
|
43
|
+
- Text with databinding {variable}
|
|
44
|
+
- Quantum tags are already executed (loops expanded, conditions evaluated)
|
|
45
|
+
- XSS protection via HTML escaping
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
# Tags whose text content should NOT be processed by databinding or HTML-escaped
|
|
49
|
+
RAW_CONTENT_TAGS = {'style', 'script'}
|
|
50
|
+
|
|
51
|
+
def __init__(self, context: ExecutionContext, components_dir: str = "./components"):
|
|
52
|
+
"""
|
|
53
|
+
Initialize renderer with execution context.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
context: ExecutionContext with all variables and query results
|
|
57
|
+
components_dir: Directory where component files are located (Phase 2)
|
|
58
|
+
"""
|
|
59
|
+
self.context = context
|
|
60
|
+
self.components_dir = components_dir
|
|
61
|
+
self._raw_mode = False
|
|
62
|
+
# Shared with ComponentRuntime: one evaluator, one grammar, so a
|
|
63
|
+
# {expression} means the same thing in the execute pass and the
|
|
64
|
+
# render pass. They used to be two hand-rolled implementations.
|
|
65
|
+
self._expressions = ExpressionEvaluator()
|
|
66
|
+
|
|
67
|
+
# Lazy-load composer (Phase 2)
|
|
68
|
+
self._composer = None
|
|
69
|
+
self._resolver = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render(self, node: QuantumNode) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Main render dispatch method.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
node: AST node to render
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
HTML string
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
if isinstance(node, HTMLNode):
|
|
84
|
+
return self._render_html_node(node)
|
|
85
|
+
|
|
86
|
+
elif isinstance(node, TextNode):
|
|
87
|
+
return self._render_text_node(node)
|
|
88
|
+
|
|
89
|
+
elif isinstance(node, DocTypeNode):
|
|
90
|
+
return self._render_doctype(node)
|
|
91
|
+
|
|
92
|
+
elif isinstance(node, CommentNode):
|
|
93
|
+
return self._render_comment(node)
|
|
94
|
+
|
|
95
|
+
elif isinstance(node, ComponentNode):
|
|
96
|
+
return self._render_component(node)
|
|
97
|
+
|
|
98
|
+
# Component Composition (Phase 2)
|
|
99
|
+
elif isinstance(node, ComponentCallNode):
|
|
100
|
+
return self._render_component_call(node)
|
|
101
|
+
|
|
102
|
+
elif isinstance(node, ImportNode):
|
|
103
|
+
# Imports are processed at component load time, not render time
|
|
104
|
+
return ''
|
|
105
|
+
|
|
106
|
+
elif isinstance(node, LoopNode):
|
|
107
|
+
return self._render_loop(node)
|
|
108
|
+
|
|
109
|
+
elif isinstance(node, IfNode):
|
|
110
|
+
return self._render_if(node)
|
|
111
|
+
|
|
112
|
+
# SetNode and QueryNode are executed during runtime, not rendered
|
|
113
|
+
elif isinstance(node, (SetNode, QueryNode)):
|
|
114
|
+
return ''
|
|
115
|
+
|
|
116
|
+
else:
|
|
117
|
+
# Unknown node type - skip (includes QuantumReturn and others)
|
|
118
|
+
return ''
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def render_all(self, nodes: List[QuantumNode]) -> str:
|
|
122
|
+
"""
|
|
123
|
+
Render list of nodes and concatenate results.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
nodes: List of AST nodes
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Concatenated HTML string
|
|
130
|
+
"""
|
|
131
|
+
return ''.join(self.render(node) for node in nodes)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _render_html_node(self, node: HTMLNode) -> str:
|
|
135
|
+
"""
|
|
136
|
+
Render HTML element with attributes and children.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
HTMLNode(tag='div', attributes={'class': 'container'}, children=[...])
|
|
140
|
+
→ <div class="container">...</div>
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
node: HTMLNode to render
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
HTML string
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
# Build opening tag
|
|
150
|
+
tag_parts = [f'<{node.tag}']
|
|
151
|
+
|
|
152
|
+
# Add attributes with databinding applied
|
|
153
|
+
if node.attributes:
|
|
154
|
+
for key, value in node.attributes.items():
|
|
155
|
+
# Apply databinding to attribute value
|
|
156
|
+
processed_value = self._apply_databinding(value)
|
|
157
|
+
# Escape for HTML attribute safety (prevent XSS)
|
|
158
|
+
escaped_value = html.escape(str(processed_value), quote=True)
|
|
159
|
+
tag_parts.append(f'{key}="{escaped_value}"')
|
|
160
|
+
|
|
161
|
+
opening_tag = ' '.join(tag_parts)
|
|
162
|
+
|
|
163
|
+
# Self-closing tags (void elements)
|
|
164
|
+
if node.self_closing:
|
|
165
|
+
return opening_tag + ' />'
|
|
166
|
+
|
|
167
|
+
# Regular tags with children
|
|
168
|
+
opening_tag += '>'
|
|
169
|
+
|
|
170
|
+
# Style/script tags: render children as raw text (no databinding, no escaping)
|
|
171
|
+
if node.tag in self.RAW_CONTENT_TAGS:
|
|
172
|
+
prev_raw = self._raw_mode
|
|
173
|
+
self._raw_mode = True
|
|
174
|
+
children_html = self.render_all(node.children)
|
|
175
|
+
self._raw_mode = prev_raw
|
|
176
|
+
else:
|
|
177
|
+
children_html = self.render_all(node.children)
|
|
178
|
+
|
|
179
|
+
closing_tag = f'</{node.tag}>'
|
|
180
|
+
|
|
181
|
+
return opening_tag + children_html + closing_tag
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _render_text_node(self, node: TextNode) -> str:
|
|
185
|
+
"""
|
|
186
|
+
Render text content with databinding applied.
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
TextNode("Hello {user.name}!") with context['user']['name'] = 'John'
|
|
190
|
+
→ "Hello John!"
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
node: TextNode to render
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Text string with databinding applied and HTML escaped
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
text = node.content
|
|
200
|
+
|
|
201
|
+
# Inside <style>/<script> tags: return raw content without databinding or escaping
|
|
202
|
+
if self._raw_mode:
|
|
203
|
+
return text
|
|
204
|
+
|
|
205
|
+
# Apply databinding if needed
|
|
206
|
+
if node.has_databinding:
|
|
207
|
+
text = self._apply_databinding(text)
|
|
208
|
+
|
|
209
|
+
# HTML escape to prevent XSS
|
|
210
|
+
# NOTE: This means you can't inject raw HTML via variables (security feature)
|
|
211
|
+
# Guard against non-string values (e.g., bound methods from getattr)
|
|
212
|
+
if callable(text):
|
|
213
|
+
text = ''
|
|
214
|
+
return html.escape(str(text) if text is not None else '')
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _render_doctype(self, node: DocTypeNode) -> str:
|
|
218
|
+
"""
|
|
219
|
+
Render DOCTYPE declaration.
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
DocTypeNode("html") → "<!DOCTYPE html>"
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
node: DocTypeNode to render
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
DOCTYPE string
|
|
229
|
+
"""
|
|
230
|
+
return f'<!DOCTYPE {node.value}>'
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _render_loop(self, node: LoopNode) -> str:
|
|
234
|
+
"""
|
|
235
|
+
Render q:loop by iterating over items and rendering body for each.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
node: LoopNode to render
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Concatenated HTML from all loop iterations
|
|
242
|
+
"""
|
|
243
|
+
result = []
|
|
244
|
+
|
|
245
|
+
# Get the items to iterate over
|
|
246
|
+
items = self._get_loop_items(node)
|
|
247
|
+
|
|
248
|
+
if not items:
|
|
249
|
+
return ''
|
|
250
|
+
|
|
251
|
+
# Save current context state
|
|
252
|
+
original_vars = self.context.local_vars.copy()
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
for index, item in enumerate(items):
|
|
256
|
+
# Set loop variable in context
|
|
257
|
+
self.context.set_variable(node.var_name, item, scope="local")
|
|
258
|
+
|
|
259
|
+
# For query loops, set dotted variables (e.g., task.title, task.status)
|
|
260
|
+
if node.loop_type == 'query' and isinstance(item, dict):
|
|
261
|
+
for field_name, field_value in item.items():
|
|
262
|
+
dotted_key = f"{node.var_name}.{field_name}"
|
|
263
|
+
self.context.set_variable(dotted_key, field_value, scope="local")
|
|
264
|
+
|
|
265
|
+
# Set index if specified
|
|
266
|
+
if node.index_name:
|
|
267
|
+
self.context.set_variable(node.index_name, index, scope="local")
|
|
268
|
+
|
|
269
|
+
# Render loop body
|
|
270
|
+
for child in node.body:
|
|
271
|
+
result.append(self.render(child))
|
|
272
|
+
|
|
273
|
+
finally:
|
|
274
|
+
# Restore original context
|
|
275
|
+
self.context.local_vars = original_vars
|
|
276
|
+
|
|
277
|
+
return ''.join(result)
|
|
278
|
+
|
|
279
|
+
def _get_loop_items(self, node: LoopNode) -> list:
|
|
280
|
+
"""Get items to iterate over from loop node."""
|
|
281
|
+
if node.loop_type == 'array':
|
|
282
|
+
# Get items expression
|
|
283
|
+
items_expr = node.items
|
|
284
|
+
if not items_expr:
|
|
285
|
+
return []
|
|
286
|
+
|
|
287
|
+
# Apply databinding to resolve variable reference
|
|
288
|
+
if '{' in items_expr and '}' in items_expr:
|
|
289
|
+
resolved = self._apply_databinding(items_expr)
|
|
290
|
+
if isinstance(resolved, list):
|
|
291
|
+
return resolved
|
|
292
|
+
elif isinstance(resolved, str):
|
|
293
|
+
# Try to parse as JSON
|
|
294
|
+
try:
|
|
295
|
+
import json
|
|
296
|
+
return json.loads(resolved)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
# A loop that renders NOTHING is indistinguishable from
|
|
299
|
+
# an empty collection, so an unresolvable items= used
|
|
300
|
+
# to look like "no results" on the page.
|
|
301
|
+
logger.warning(
|
|
302
|
+
"q:loop items=%r is not a list and not JSON (%s); "
|
|
303
|
+
"rendering zero rows", items_expr, exc
|
|
304
|
+
)
|
|
305
|
+
return []
|
|
306
|
+
logger.warning(
|
|
307
|
+
"q:loop items=%r did not resolve to a list; rendering zero rows",
|
|
308
|
+
items_expr
|
|
309
|
+
)
|
|
310
|
+
return []
|
|
311
|
+
|
|
312
|
+
elif node.loop_type == 'range':
|
|
313
|
+
# Generate range
|
|
314
|
+
try:
|
|
315
|
+
start = int(node.from_value) if node.from_value else 1
|
|
316
|
+
end = int(node.to_value) if node.to_value else 10
|
|
317
|
+
step = node.step_value if node.step_value else 1
|
|
318
|
+
return list(range(start, end + 1, step))
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
# from=/to= that did not resolve (a databinding placeholder,
|
|
321
|
+
# a typo) silently produced an empty range.
|
|
322
|
+
logger.warning(
|
|
323
|
+
"q:loop range from=%r to=%r step=%r is not usable (%s); "
|
|
324
|
+
"rendering zero rows",
|
|
325
|
+
node.from_value, node.to_value, node.step_value, exc
|
|
326
|
+
)
|
|
327
|
+
return []
|
|
328
|
+
|
|
329
|
+
elif node.loop_type == 'query':
|
|
330
|
+
# Query loop - resolve items from query result variable
|
|
331
|
+
items_expr = node.items
|
|
332
|
+
if not items_expr:
|
|
333
|
+
# Shorthand syntax: query_name is in var_name
|
|
334
|
+
query_name = getattr(node, 'query_name', node.var_name)
|
|
335
|
+
try:
|
|
336
|
+
data = self.context.get_variable(query_name)
|
|
337
|
+
if isinstance(data, list):
|
|
338
|
+
return data
|
|
339
|
+
logger.warning(
|
|
340
|
+
"q:loop query=%r resolved to %s, not a list; "
|
|
341
|
+
"rendering zero rows", query_name, type(data).__name__
|
|
342
|
+
)
|
|
343
|
+
except Exception:
|
|
344
|
+
logger.warning(
|
|
345
|
+
"q:loop query=%r is not defined — did the q:query run, "
|
|
346
|
+
"and is the name spelled the same? Rendering zero rows.",
|
|
347
|
+
query_name
|
|
348
|
+
)
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
# Traditional syntax: items="{tasks}"
|
|
352
|
+
if '{' in items_expr and '}' in items_expr:
|
|
353
|
+
resolved = self._apply_databinding(items_expr)
|
|
354
|
+
if isinstance(resolved, list):
|
|
355
|
+
return resolved
|
|
356
|
+
return []
|
|
357
|
+
|
|
358
|
+
elif node.loop_type == 'list':
|
|
359
|
+
# Split by delimiter
|
|
360
|
+
items_expr = node.items
|
|
361
|
+
if not items_expr:
|
|
362
|
+
return []
|
|
363
|
+
resolved = self._apply_databinding(items_expr) if '{' in items_expr else items_expr
|
|
364
|
+
delimiter = node.delimiter or ','
|
|
365
|
+
return [item.strip() for item in str(resolved).split(delimiter)]
|
|
366
|
+
|
|
367
|
+
return []
|
|
368
|
+
|
|
369
|
+
def _render_if(self, node: IfNode) -> str:
|
|
370
|
+
"""
|
|
371
|
+
Render q:if by evaluating condition and rendering appropriate branch.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
node: IfNode to render
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
HTML from the matching branch
|
|
378
|
+
"""
|
|
379
|
+
# Evaluate main condition
|
|
380
|
+
if self._evaluate_condition(node.condition):
|
|
381
|
+
return self.render_all(node.if_body)
|
|
382
|
+
|
|
383
|
+
# Check elseif branches.
|
|
384
|
+
#
|
|
385
|
+
# The parser stores these as DICTS — {"condition": ..., "body": ...} —
|
|
386
|
+
# and IfExecutor reads them that way. This pass read them as objects
|
|
387
|
+
# (elseif.condition), so any page using q:elseif died with
|
|
388
|
+
# "'dict' object has no attribute 'condition'" during render. The two
|
|
389
|
+
# passes disagreed about the same structure and only one was ever run
|
|
390
|
+
# in a test. Both shapes are accepted here so a future change to
|
|
391
|
+
# either side cannot resurrect the crash.
|
|
392
|
+
for elseif in (node.elseif_blocks or []):
|
|
393
|
+
if isinstance(elseif, dict):
|
|
394
|
+
cond, body = elseif.get("condition"), elseif.get("body")
|
|
395
|
+
else:
|
|
396
|
+
cond, body = getattr(elseif, "condition", None), getattr(elseif, "body", None)
|
|
397
|
+
if cond is not None and self._evaluate_condition(cond):
|
|
398
|
+
return self.render_all(body or [])
|
|
399
|
+
|
|
400
|
+
# Else branch
|
|
401
|
+
if node.else_body:
|
|
402
|
+
return self.render_all(node.else_body)
|
|
403
|
+
|
|
404
|
+
return ''
|
|
405
|
+
|
|
406
|
+
def _evaluate_condition(self, condition: str) -> bool:
|
|
407
|
+
"""Evaluate a condition expression."""
|
|
408
|
+
if not condition:
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
# Evaluate the condition as an expression, without interpolating any
|
|
412
|
+
# values into it first.
|
|
413
|
+
#
|
|
414
|
+
# This used to build a string by substituting databinding results into
|
|
415
|
+
# the condition and then eval() it. That is not a comparison, it is
|
|
416
|
+
# code assembly from data: a record whose name is "'; __import__(...)"
|
|
417
|
+
# became executable code. Stripping the braces instead leaves the
|
|
418
|
+
# variable NAMES in place and lets the evaluator resolve them, so no
|
|
419
|
+
# value is ever parsed as syntax. PUBLIC_RELEASE_PLAN.md P0.1.
|
|
420
|
+
stripped = re.sub(r'\{([^}]+)\}', r'\1', condition).strip()
|
|
421
|
+
try:
|
|
422
|
+
return bool(self._expressions.evaluate(
|
|
423
|
+
stripped, self.context.get_all_variables()
|
|
424
|
+
))
|
|
425
|
+
except ExpressionError:
|
|
426
|
+
# A condition that could not be evaluated is FALSE, not true.
|
|
427
|
+
#
|
|
428
|
+
# This used to fall through to _apply_databinding, which hands back
|
|
429
|
+
# the literal placeholder '{session.user.is_admin}' — a non-empty
|
|
430
|
+
# string, which the truthiness rule below then read as True. So
|
|
431
|
+
# <q:if condition="session.user.is_admin">
|
|
432
|
+
# rendered its admin branch for a logged-out visitor whose session
|
|
433
|
+
# had no `user`, and every q:else guarding it never fired. A
|
|
434
|
+
# condition failing open is an authorization bypass by
|
|
435
|
+
# construction. Reported by two audit dimensions, reproduced
|
|
436
|
+
# directly. See tests/integration/test_condition_fails_closed.py.
|
|
437
|
+
report_unresolved(stripped, ExpressionError(
|
|
438
|
+
f"condition {condition!r} could not be evaluated; treated as false"
|
|
439
|
+
))
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
def _render_comment(self, node: CommentNode) -> str:
|
|
443
|
+
"""
|
|
444
|
+
Render HTML comment.
|
|
445
|
+
|
|
446
|
+
Example:
|
|
447
|
+
CommentNode("This is a comment") → "<!-- This is a comment -->"
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
node: CommentNode to render
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
HTML comment string
|
|
454
|
+
"""
|
|
455
|
+
return f'<!-- {node.content} -->'
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# Um handler inline: onclick="salvar()", onchange="atualizar(this)".
|
|
459
|
+
_INLINE_HANDLER = re.compile(
|
|
460
|
+
r'\son[a-z]+\s*=\s*"([^"]*)"|\son[a-z]+\s*=\s*\'([^\']*)\'',
|
|
461
|
+
re.IGNORECASE)
|
|
462
|
+
# O nome chamado dentro dele.
|
|
463
|
+
_CALLED_NAME = re.compile(r'\b([A-Za-z_$][\w$]*)\s*\(')
|
|
464
|
+
|
|
465
|
+
def _render_component(self, node: ComponentNode) -> str:
|
|
466
|
+
"""
|
|
467
|
+
Render entire component body.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
node: ComponentNode to render
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
HTML string of component output
|
|
474
|
+
"""
|
|
475
|
+
html = self.render_all(node.statements)
|
|
476
|
+
script = self._explain_server_side_handlers(node, html)
|
|
477
|
+
if not script:
|
|
478
|
+
return html
|
|
479
|
+
# Dentro do documento quando ha um: depois de `</html>` o navegador
|
|
480
|
+
# reposiciona o script sozinho, mas o HTML deixa de ser valido e
|
|
481
|
+
# qualquer ferramenta que leia a saida tropeca.
|
|
482
|
+
for fecho in ('</body>', '</html>'):
|
|
483
|
+
posicao = html.rfind(fecho)
|
|
484
|
+
if posicao != -1:
|
|
485
|
+
return html[:posicao] + script + '\n' + html[posicao:]
|
|
486
|
+
return html + script
|
|
487
|
+
|
|
488
|
+
def _explain_server_side_handlers(self, node: ComponentNode,
|
|
489
|
+
html: str) -> str:
|
|
490
|
+
"""Um `onclick="minhaFuncao()"` que aponta para uma q:function.
|
|
491
|
+
|
|
492
|
+
O corpo de uma q:function nunca vai para o cliente — ela roda no
|
|
493
|
+
servidor, com q:set/q:query/q:invoke, e nao ha rota para chama-la do
|
|
494
|
+
navegador (esse e o papel do q:action). Mesmo assim o HTML saia com
|
|
495
|
+
`onclick="minhaFuncao()"` e nenhuma definicao de `minhaFuncao` em
|
|
496
|
+
lugar nenhum: o clique dava `ReferenceError` no console do navegador
|
|
497
|
+
e absolutamente nada na tela, no log do servidor ou em qualquer
|
|
498
|
+
outro lugar. Quem escreveu nao tinha como saber.
|
|
499
|
+
|
|
500
|
+
Traduzir o corpo para JavaScript nao e possivel em geral — ele le o
|
|
501
|
+
banco. Entao o que se pode fazer com honestidade e nomear a falha:
|
|
502
|
+
um aviso no log do servidor, na hora de renderizar, e uma definicao
|
|
503
|
+
no cliente que levanta um erro dizendo o que fazer.
|
|
504
|
+
"""
|
|
505
|
+
declaradas = {
|
|
506
|
+
f.name for f in getattr(node, 'functions', None) or []
|
|
507
|
+
if getattr(f, 'name', None)
|
|
508
|
+
}
|
|
509
|
+
if not declaradas or not html:
|
|
510
|
+
return ''
|
|
511
|
+
|
|
512
|
+
chamadas = set()
|
|
513
|
+
for m in self._INLINE_HANDLER.finditer(html):
|
|
514
|
+
corpo = m.group(1) if m.group(1) is not None else m.group(2)
|
|
515
|
+
for nome in self._CALLED_NAME.findall(corpo or ''):
|
|
516
|
+
if nome in declaradas:
|
|
517
|
+
chamadas.add(nome)
|
|
518
|
+
|
|
519
|
+
if not chamadas:
|
|
520
|
+
return ''
|
|
521
|
+
|
|
522
|
+
for nome in sorted(chamadas):
|
|
523
|
+
logger.warning(
|
|
524
|
+
"%s: an inline handler calls q:function %r, but a q:function "
|
|
525
|
+
"runs on the server and is never sent to the browser - the "
|
|
526
|
+
"click will raise ReferenceError. Use <q:action> for a "
|
|
527
|
+
"server-side handler, or write the JavaScript in <q:script>.",
|
|
528
|
+
getattr(node, 'name', '<component>'), nome
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# A mensagem sai numa variavel antes de entrar na f-string. Ter a
|
|
532
|
+
# expressao quebrada em varias linhas DENTRO das chaves e PEP 701,
|
|
533
|
+
# valido so no Python 3.12+ — e o pyproject declara 3.11. Rodando so
|
|
534
|
+
# 3.12 aqui, isso passou despercebido ate o CI (matriz 3.11/3.12)
|
|
535
|
+
# recusar o modulo inteiro com SyntaxError na coleta.
|
|
536
|
+
linhas_js = []
|
|
537
|
+
for nome in sorted(chamadas):
|
|
538
|
+
recado = (
|
|
539
|
+
f'q:function "{nome}" runs on the server and is not '
|
|
540
|
+
f'available in the browser. Use <q:action> to handle this '
|
|
541
|
+
f'from the page, or define the handler in <q:script>.'
|
|
542
|
+
)
|
|
543
|
+
alvo = json.dumps(nome)
|
|
544
|
+
linhas_js.append(
|
|
545
|
+
f" window[{alvo}] = window[{alvo}] || "
|
|
546
|
+
f"function () {{ throw new Error({json.dumps(recado)}); }};"
|
|
547
|
+
)
|
|
548
|
+
corpo_js = '\n'.join(linhas_js)
|
|
549
|
+
# `</` vira `<\/`: nenhum texto dentro do script pode fechar a tag
|
|
550
|
+
# antes da hora.
|
|
551
|
+
corpo_js = corpo_js.replace('</', '<\\/')
|
|
552
|
+
return f"\n<script>\n{corpo_js}\n</script>"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _apply_databinding(self, text: str) -> Any:
|
|
556
|
+
"""
|
|
557
|
+
Replace {variable} and {expression} with actual values from context.
|
|
558
|
+
|
|
559
|
+
Examples:
|
|
560
|
+
"{name}" → "John"
|
|
561
|
+
"{user.email}" → "john@example.com"
|
|
562
|
+
"{price * quantity}" → "49.99"
|
|
563
|
+
"{items.length}" → "5"
|
|
564
|
+
"Total: ${product.price}" → "Total: $29.99"
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
text: Text with possible {expression} patterns
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
- For pure expressions like "{items}": the actual value (list, dict, etc.)
|
|
571
|
+
- For mixed content like "Hello {name}": interpolated string
|
|
572
|
+
"""
|
|
573
|
+
if not text:
|
|
574
|
+
return text
|
|
575
|
+
|
|
576
|
+
pattern = r'\{([^}]+)\}'
|
|
577
|
+
|
|
578
|
+
# Check if the ENTIRE text is just a single databinding expression
|
|
579
|
+
full_match = re.fullmatch(pattern, text.strip())
|
|
580
|
+
if full_match:
|
|
581
|
+
# Pure expression - return the actual value (not converted to string)
|
|
582
|
+
expression = full_match.group(1).strip()
|
|
583
|
+
try:
|
|
584
|
+
return self._evaluate_expression(expression)
|
|
585
|
+
except Exception as exc:
|
|
586
|
+
report_unresolved(expression, exc)
|
|
587
|
+
# If evaluation fails, return original placeholder
|
|
588
|
+
return text
|
|
589
|
+
|
|
590
|
+
# Mixed content (text + expressions) - need string interpolation
|
|
591
|
+
def replace_binding(match):
|
|
592
|
+
"""Replace single {expression} match"""
|
|
593
|
+
expression = match.group(1).strip()
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
# Evaluate expression from context
|
|
597
|
+
value = self._evaluate_expression(expression)
|
|
598
|
+
return str(value) if value is not None else ''
|
|
599
|
+
|
|
600
|
+
except Exception as exc:
|
|
601
|
+
report_unresolved(expression, exc)
|
|
602
|
+
# The placeholder, same as the pure-expression branch above.
|
|
603
|
+
# This used to emit '{ERROR: <expr>}', so the SAME broken
|
|
604
|
+
# expression rendered two different ways depending on whether
|
|
605
|
+
# it shared a text node with other content — and the marker
|
|
606
|
+
# leaked an internal variable name onto the user's page. The
|
|
607
|
+
# diagnosis belongs in the log, which now has it.
|
|
608
|
+
return match.group(0)
|
|
609
|
+
|
|
610
|
+
# Find and replace all {expression} patterns
|
|
611
|
+
result = re.sub(pattern, replace_binding, text)
|
|
612
|
+
|
|
613
|
+
return result
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _evaluate_expression(self, expression: str) -> Any:
|
|
617
|
+
"""
|
|
618
|
+
Evaluate databinding expression from context.
|
|
619
|
+
|
|
620
|
+
Supports:
|
|
621
|
+
- Simple variables: user
|
|
622
|
+
- Nested properties: user.name, user.address.city
|
|
623
|
+
- Array access: items[0], products[2].name
|
|
624
|
+
- Array properties: items.length
|
|
625
|
+
- Simple arithmetic: price * quantity, count + 1
|
|
626
|
+
- Query results: products, products_result.recordCount
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
expression: Expression to evaluate
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
Evaluated value
|
|
633
|
+
|
|
634
|
+
Raises:
|
|
635
|
+
Exception: If expression cannot be evaluated
|
|
636
|
+
"""
|
|
637
|
+
|
|
638
|
+
# Regex quantifiers ({n}, {n,m}) are literal text, not expressions.
|
|
639
|
+
if is_regex_quantifier(expression):
|
|
640
|
+
raise ValueError(f"'{expression}' is a regex quantifier")
|
|
641
|
+
|
|
642
|
+
# Try to get from context directly (simple variable)
|
|
643
|
+
# get_variable() searches all scopes: local -> function -> component -> session -> parent
|
|
644
|
+
# This has to come first: get_all_variables() stores scoped variables
|
|
645
|
+
# under dotted KEYS ('session.user'), which is a lookup, not an
|
|
646
|
+
# attribute access, so the evaluator cannot see them.
|
|
647
|
+
try:
|
|
648
|
+
value = self.context.get_variable(expression)
|
|
649
|
+
return value
|
|
650
|
+
except:
|
|
651
|
+
pass # Variable not found, try other evaluation methods
|
|
652
|
+
|
|
653
|
+
# The shared evaluator (FRAMEWORK_PLAN.md Fase 2.1) handles arithmetic,
|
|
654
|
+
# comparison, indexing, attribute access and stdlib calls in one place.
|
|
655
|
+
#
|
|
656
|
+
# Four hand-rolled methods used to sit below this as a fallback —
|
|
657
|
+
# _evaluate_nested_property, _evaluate_array_access,
|
|
658
|
+
# _evaluate_comparison and _evaluate_arithmetic. Instrumenting the
|
|
659
|
+
# whole suite after the evaluator went in showed all four at zero
|
|
660
|
+
# calls, so they were deleted rather than kept as reassurance. Their
|
|
661
|
+
# `if value is not None: return value` shape was a bug source anyway:
|
|
662
|
+
# a property that legitimately held None fell through to the next
|
|
663
|
+
# guess instead of resolving.
|
|
664
|
+
try:
|
|
665
|
+
return self._expressions.evaluate(
|
|
666
|
+
expression, self.context.get_all_variables()
|
|
667
|
+
)
|
|
668
|
+
except ExpressionError:
|
|
669
|
+
# Absent scoped variables resolve to '', the same contract
|
|
670
|
+
# ComponentRuntime honours. Without this the runtime rendered ''
|
|
671
|
+
# and the renderer rendered the literal '{form.email}' for the
|
|
672
|
+
# same expression — one broken thing, two outputs, which is the
|
|
673
|
+
# bug this phase set out to remove.
|
|
674
|
+
if is_absent_scope(expression):
|
|
675
|
+
return ''
|
|
676
|
+
# Otherwise raised as-is. Wrapping it in "Cannot evaluate
|
|
677
|
+
# expression: <expr> (<reason>)" repeated the expression the
|
|
678
|
+
# caller already reports and buried the reason — the useful half —
|
|
679
|
+
# in parentheses at the end of a doubled sentence.
|
|
680
|
+
raise
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# ============================================
|
|
684
|
+
# COMPONENT COMPOSITION RENDERING (Phase 2)
|
|
685
|
+
# ============================================
|
|
686
|
+
|
|
687
|
+
def _get_composer(self):
|
|
688
|
+
"""Lazy-load component composer"""
|
|
689
|
+
if self._composer is None:
|
|
690
|
+
from quantum.runtime.component_resolver import ComponentResolver
|
|
691
|
+
from quantum.runtime.component_composer import ComponentComposer
|
|
692
|
+
|
|
693
|
+
self._resolver = ComponentResolver(self.components_dir)
|
|
694
|
+
self._composer = ComponentComposer(self._resolver)
|
|
695
|
+
|
|
696
|
+
return self._composer
|
|
697
|
+
|
|
698
|
+
def _render_component_call(self, node: ComponentCallNode) -> str:
|
|
699
|
+
"""
|
|
700
|
+
Render component call (Phase 2).
|
|
701
|
+
|
|
702
|
+
Examples:
|
|
703
|
+
<Header title="Products" />
|
|
704
|
+
<Button label="Save" color="green" />
|
|
705
|
+
<Card title="Info">Content here</Card>
|
|
706
|
+
|
|
707
|
+
Args:
|
|
708
|
+
node: ComponentCallNode from AST
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
Rendered HTML from child component
|
|
712
|
+
"""
|
|
713
|
+
|
|
714
|
+
try:
|
|
715
|
+
composer = self._get_composer()
|
|
716
|
+
html = composer.compose(node, self.context)
|
|
717
|
+
return html
|
|
718
|
+
|
|
719
|
+
except Exception as e:
|
|
720
|
+
# A child component that fails becomes an HTML COMMENT, so the
|
|
721
|
+
# page still returns 200 with that section simply missing — the
|
|
722
|
+
# database being down looked like a page with no results.
|
|
723
|
+
#
|
|
724
|
+
# The comment stays (removing it would blank the section with no
|
|
725
|
+
# trace at all in the markup), but the failure is now LOGGED with
|
|
726
|
+
# its traceback, so it exists somewhere a person will look. It
|
|
727
|
+
# also carries the component name, which is what you need to find
|
|
728
|
+
# it among a page full of children.
|
|
729
|
+
logger.exception(
|
|
730
|
+
"component <%s> failed to render and was replaced by a comment: %s",
|
|
731
|
+
node.component_name, e
|
|
732
|
+
)
|
|
733
|
+
return f'<!-- Component Error ({node.component_name}): {e} -->'
|