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,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Partial rebuild support for TW Framework.
|
|
3
|
+
|
|
4
|
+
When only one page changes, rebuild only affected pages using the dependency graph.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import List, Set
|
|
9
|
+
|
|
10
|
+
from . import compiler
|
|
11
|
+
from .dependency_graph import DependencyGraph
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_pages_to_rebuild(project_root: str, changed_files: Set[str]) -> List[dict]:
|
|
17
|
+
"""Return the list of pages that need to be rebuilt given the changed files."""
|
|
18
|
+
graph = DependencyGraph(project_root)
|
|
19
|
+
affected = graph.get_affected_nodes(changed_files)
|
|
20
|
+
pages = compiler.discover_pages()
|
|
21
|
+
return [p for p in pages if p["path"] in affected]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["get_pages_to_rebuild"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime performance analyzer for TW Framework.
|
|
3
|
+
|
|
4
|
+
Measures render time, SSR time, middleware time, and API execution time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PerformanceAnalyzer:
|
|
15
|
+
"""Collects runtime performance metrics."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.metrics: Dict[str, float] = {}
|
|
19
|
+
|
|
20
|
+
def measure(self, name: str, func, *args, **kwargs) -> Any:
|
|
21
|
+
start = time.perf_counter()
|
|
22
|
+
try:
|
|
23
|
+
result = func(*args, **kwargs)
|
|
24
|
+
return result
|
|
25
|
+
finally:
|
|
26
|
+
elapsed = time.perf_counter() - start
|
|
27
|
+
self.metrics[name] = elapsed
|
|
28
|
+
logger.debug("Performance: %s took %.4f seconds", name, elapsed)
|
|
29
|
+
|
|
30
|
+
def get_report(self) -> Dict[str, float]:
|
|
31
|
+
return dict(self.metrics)
|
|
32
|
+
|
|
33
|
+
def reset(self):
|
|
34
|
+
self.metrics.clear()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["PerformanceAnalyzer"]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import traceback
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .common import content_hash
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
HOOK_ALIASES = {
|
|
13
|
+
"before_build": "beforeBuild",
|
|
14
|
+
"after_build": "afterBuild",
|
|
15
|
+
"before_route": "beforeRoute",
|
|
16
|
+
"after_route": "afterRoute",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class LoadedExtension:
|
|
22
|
+
name: str
|
|
23
|
+
path: str
|
|
24
|
+
module: object
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PluginAPI:
|
|
28
|
+
def __init__(self, manager, extension_name):
|
|
29
|
+
self.manager = manager
|
|
30
|
+
self.extension_name = extension_name
|
|
31
|
+
|
|
32
|
+
def register_hook(self, event_name, handler):
|
|
33
|
+
self.manager.register_hook(event_name, handler, self.extension_name)
|
|
34
|
+
|
|
35
|
+
def hook(self, event_name, handler):
|
|
36
|
+
self.register_hook(event_name, handler)
|
|
37
|
+
|
|
38
|
+
def get_config(self):
|
|
39
|
+
return dict(self.manager.config or {})
|
|
40
|
+
|
|
41
|
+
def get_env(self):
|
|
42
|
+
return dict(self.manager.env or {})
|
|
43
|
+
|
|
44
|
+
def get_project_root(self):
|
|
45
|
+
return self.manager.project_root
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ExtensionManager:
|
|
49
|
+
def __init__(self, project_root, config=None, env=None):
|
|
50
|
+
self.project_root = os.path.abspath(project_root)
|
|
51
|
+
self.config = dict(config or {})
|
|
52
|
+
self.env = dict(env or {})
|
|
53
|
+
self.extensions = []
|
|
54
|
+
self.hooks = {}
|
|
55
|
+
self.errors = []
|
|
56
|
+
|
|
57
|
+
def refresh(self, config=None, env=None):
|
|
58
|
+
self.config = dict(config or self.config or {})
|
|
59
|
+
self.env = dict(env or self.env or {})
|
|
60
|
+
self.extensions = []
|
|
61
|
+
self.hooks = {}
|
|
62
|
+
self.errors = []
|
|
63
|
+
extension_paths = self.discover_extension_paths()
|
|
64
|
+
self._warn_extension_execution(extension_paths)
|
|
65
|
+
for path in extension_paths:
|
|
66
|
+
self._load_extension(path)
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def discover_extension_paths(self):
|
|
70
|
+
paths = []
|
|
71
|
+
for folder in self._extension_roots():
|
|
72
|
+
if not os.path.isdir(folder):
|
|
73
|
+
continue
|
|
74
|
+
for dirpath, _, filenames in os.walk(folder):
|
|
75
|
+
for filename in sorted(filenames):
|
|
76
|
+
if not filename.endswith(".py") or filename.startswith("_"):
|
|
77
|
+
continue
|
|
78
|
+
paths.append(os.path.join(dirpath, filename))
|
|
79
|
+
normalized = sorted(set(os.path.abspath(path) for path in paths))
|
|
80
|
+
return self._apply_allowlist(normalized)
|
|
81
|
+
|
|
82
|
+
def dependency_paths(self):
|
|
83
|
+
return self.discover_extension_paths()
|
|
84
|
+
|
|
85
|
+
def emit(self, event_name, **payload):
|
|
86
|
+
canonical = self._normalize_event_name(event_name)
|
|
87
|
+
event_payload = dict(payload)
|
|
88
|
+
event_payload.setdefault("event", canonical)
|
|
89
|
+
event_payload.setdefault("project_root", self.project_root)
|
|
90
|
+
event_payload.setdefault("config", dict(self.config))
|
|
91
|
+
event_payload.setdefault("env", dict(self.env))
|
|
92
|
+
for hook in self.hooks.get(canonical, []):
|
|
93
|
+
try:
|
|
94
|
+
result = hook["handler"](event_payload)
|
|
95
|
+
if isinstance(result, dict):
|
|
96
|
+
event_payload.update(result)
|
|
97
|
+
except Exception as err:
|
|
98
|
+
tb = traceback.format_exc()
|
|
99
|
+
logger.exception("Extension hook failed: %s::%s", hook.get("extension"), canonical)
|
|
100
|
+
self.errors.append(f"{hook['extension']}::{canonical}: {err}\n{tb}")
|
|
101
|
+
return event_payload
|
|
102
|
+
|
|
103
|
+
def register_hook(self, event_name, handler, extension_name):
|
|
104
|
+
canonical = self._normalize_event_name(event_name)
|
|
105
|
+
if not callable(handler):
|
|
106
|
+
raise TypeError(f"Hook `{canonical}` from `{extension_name}` must be callable")
|
|
107
|
+
self.hooks.setdefault(canonical, []).append({
|
|
108
|
+
"extension": extension_name,
|
|
109
|
+
"handler": handler,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
def _extension_roots(self):
|
|
113
|
+
home_dir = os.path.join(self.project_root, "[home]")
|
|
114
|
+
return [
|
|
115
|
+
os.path.join(home_dir, "plugins"),
|
|
116
|
+
os.path.join(home_dir, "hooks"),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def _normalize_event_name(self, event_name):
|
|
120
|
+
raw = str(event_name or "").strip()
|
|
121
|
+
return HOOK_ALIASES.get(raw, raw)
|
|
122
|
+
|
|
123
|
+
def _configured_allowlist(self):
|
|
124
|
+
raw = (
|
|
125
|
+
self.config.get("plugin_allowlist")
|
|
126
|
+
or self.config.get("plugins_allowlist")
|
|
127
|
+
or self.config.get("extension_allowlist")
|
|
128
|
+
or self.config.get("extensions_allowlist")
|
|
129
|
+
or []
|
|
130
|
+
)
|
|
131
|
+
if isinstance(raw, str):
|
|
132
|
+
raw = [raw]
|
|
133
|
+
if not isinstance(raw, (list, tuple, set)):
|
|
134
|
+
return set()
|
|
135
|
+
return {
|
|
136
|
+
str(item).strip().replace("\\", "/")
|
|
137
|
+
for item in raw
|
|
138
|
+
if str(item).strip()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def _apply_allowlist(self, paths):
|
|
142
|
+
allowlist = self._configured_allowlist()
|
|
143
|
+
if not allowlist:
|
|
144
|
+
return paths
|
|
145
|
+
allowed = []
|
|
146
|
+
blocked = []
|
|
147
|
+
for path in paths:
|
|
148
|
+
rel = os.path.relpath(path, self.project_root).replace("\\", "/")
|
|
149
|
+
base = os.path.basename(path)
|
|
150
|
+
if rel in allowlist or base in allowlist:
|
|
151
|
+
allowed.append(path)
|
|
152
|
+
else:
|
|
153
|
+
blocked.append(rel)
|
|
154
|
+
if blocked:
|
|
155
|
+
logger.warning(
|
|
156
|
+
"Skipping non-allowlisted extensions: %s",
|
|
157
|
+
", ".join(sorted(blocked)),
|
|
158
|
+
)
|
|
159
|
+
return allowed
|
|
160
|
+
|
|
161
|
+
def _warn_extension_execution(self, paths):
|
|
162
|
+
if not paths:
|
|
163
|
+
return
|
|
164
|
+
rendered = ", ".join(
|
|
165
|
+
sorted(os.path.relpath(path, self.project_root).replace("\\", "/") for path in paths)
|
|
166
|
+
)
|
|
167
|
+
if self._configured_allowlist():
|
|
168
|
+
logger.warning("Executing allowlisted TW extensions: %s", rendered)
|
|
169
|
+
else:
|
|
170
|
+
logger.warning(
|
|
171
|
+
"Executing TW extensions from project code: %s. Review these files carefully or configure `plugin_allowlist` in `tw.config`.",
|
|
172
|
+
rendered,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _load_extension(self, path):
|
|
176
|
+
name = os.path.splitext(os.path.basename(path))[0]
|
|
177
|
+
module_name = f"tw_ext_{name}_{content_hash(path, length=10)}"
|
|
178
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
179
|
+
if spec is None or spec.loader is None:
|
|
180
|
+
self.errors.append(f"{path}: failed to create import spec")
|
|
181
|
+
return
|
|
182
|
+
module = importlib.util.module_from_spec(spec)
|
|
183
|
+
try:
|
|
184
|
+
spec.loader.exec_module(module)
|
|
185
|
+
api = PluginAPI(self, name)
|
|
186
|
+
if hasattr(module, "register") and callable(module.register):
|
|
187
|
+
module.register(api)
|
|
188
|
+
hooks = getattr(module, "hooks", None)
|
|
189
|
+
if isinstance(hooks, dict):
|
|
190
|
+
for event_name, handler in hooks.items():
|
|
191
|
+
api.register_hook(event_name, handler)
|
|
192
|
+
self.extensions.append(LoadedExtension(name=name, path=path, module=module))
|
|
193
|
+
except Exception as err:
|
|
194
|
+
tb = traceback.format_exc()
|
|
195
|
+
logger.exception("Failed to load extension: %s", path)
|
|
196
|
+
self.errors.append(f"{path}: {err}\n{tb}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Production optimization for TW Framework.
|
|
3
|
+
|
|
4
|
+
Generates production output with minified HTML, CSS, JS, compressed assets,
|
|
5
|
+
and hashed filenames.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import gzip
|
|
9
|
+
import hashlib
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from . import compiler
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def optimize_for_production(output_dir: str):
|
|
21
|
+
"""Apply production optimizations to the output directory."""
|
|
22
|
+
# Minify HTML files
|
|
23
|
+
for root, _, files in os.walk(output_dir):
|
|
24
|
+
for fname in files:
|
|
25
|
+
if fname.endswith(".html"):
|
|
26
|
+
path = os.path.join(root, fname)
|
|
27
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
28
|
+
content = f.read()
|
|
29
|
+
minified = compiler.minify_html_content(content)
|
|
30
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
31
|
+
f.write(minified)
|
|
32
|
+
|
|
33
|
+
# Minify CSS files
|
|
34
|
+
for root, _, files in os.walk(output_dir):
|
|
35
|
+
for fname in files:
|
|
36
|
+
if fname.endswith(".css"):
|
|
37
|
+
path = os.path.join(root, fname)
|
|
38
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
39
|
+
content = f.read()
|
|
40
|
+
minified = compiler.minify_css_content(content)
|
|
41
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
42
|
+
f.write(minified)
|
|
43
|
+
|
|
44
|
+
# Minify JS files
|
|
45
|
+
for root, _, files in os.walk(output_dir):
|
|
46
|
+
for fname in files:
|
|
47
|
+
if fname.endswith(".js"):
|
|
48
|
+
path = os.path.join(root, fname)
|
|
49
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
50
|
+
content = f.read()
|
|
51
|
+
minified = compiler.minify_js_content(content)
|
|
52
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
53
|
+
f.write(minified)
|
|
54
|
+
|
|
55
|
+
# Gzip compress files
|
|
56
|
+
for root, _, files in os.walk(output_dir):
|
|
57
|
+
for fname in files:
|
|
58
|
+
if fname.endswith((".html", ".css", ".js", ".json", ".xml", ".txt", ".svg")):
|
|
59
|
+
path = os.path.join(root, fname)
|
|
60
|
+
with open(path, "rb") as f:
|
|
61
|
+
data = f.read()
|
|
62
|
+
with gzip.open(path + ".gz", "wb", compresslevel=9) as gz:
|
|
63
|
+
gz.write(data)
|
|
64
|
+
|
|
65
|
+
# Hash filenames for cache busting
|
|
66
|
+
for root, _, files in os.walk(output_dir):
|
|
67
|
+
for fname in files:
|
|
68
|
+
if fname.endswith((".css", ".js")):
|
|
69
|
+
path = os.path.join(root, fname)
|
|
70
|
+
with open(path, "rb") as f:
|
|
71
|
+
data = f.read()
|
|
72
|
+
digest = hashlib.sha256(data).hexdigest()[:12]
|
|
73
|
+
name, ext = os.path.splitext(fname)
|
|
74
|
+
hashed_name = f"{name}.{digest}{ext}"
|
|
75
|
+
hashed_path = os.path.join(root, hashed_name)
|
|
76
|
+
if not os.path.exists(hashed_path):
|
|
77
|
+
shutil.copy2(path, hashed_path)
|
|
78
|
+
# Remove original file
|
|
79
|
+
os.remove(path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["optimize_for_production"]
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TW Client Reactivity System
|
|
3
|
+
Generates a lightweight JS runtime injected into pages that use:
|
|
4
|
+
- state { ... } -> reactive variables
|
|
5
|
+
- bind:value -> two-way input binding
|
|
6
|
+
- on:click / on:input -> event handlers
|
|
7
|
+
- show:condition -> conditional display
|
|
8
|
+
- tw-for / tw-each -> client-side list rendering (from JSON endpoint or inline array)
|
|
9
|
+
- tw-ref -> DOM refs
|
|
10
|
+
|
|
11
|
+
This module:
|
|
12
|
+
1. Parses .tw page/component nodes and detects reactivity usage
|
|
13
|
+
2. Emits a minimal vanilla-JS runtime (~2KB gzipped) per page
|
|
14
|
+
3. Hooks into the compiler's render pipeline via page-level `script {}` injection
|
|
15
|
+
|
|
16
|
+
No framework deps. No virtual DOM. Direct DOM updates.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
# ─── Runtime JS (injected once per page that uses reactivity) ─────────────────
|
|
23
|
+
|
|
24
|
+
_TW_REACTIVE_RUNTIME = r"""
|
|
25
|
+
(function() {
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
// ── State store ──────────────────────────────────────────────────────────────
|
|
29
|
+
var __twState = {};
|
|
30
|
+
var __twWatchers = {};
|
|
31
|
+
var __twRefs = {};
|
|
32
|
+
|
|
33
|
+
function __twDefineState(initialState) {
|
|
34
|
+
Object.keys(initialState).forEach(function(key) {
|
|
35
|
+
__twState[key] = initialState[key];
|
|
36
|
+
});
|
|
37
|
+
__twSync();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function __twSet(key, value) {
|
|
41
|
+
if (__twState[key] === value) return;
|
|
42
|
+
__twState[key] = value;
|
|
43
|
+
(__twWatchers[key] || []).forEach(function(fn) { try { fn(value); } catch(e){} });
|
|
44
|
+
__twSync();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function __twGet(key) {
|
|
48
|
+
return __twState[key];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function __twWatch(key, fn) {
|
|
52
|
+
if (!__twWatchers[key]) __twWatchers[key] = [];
|
|
53
|
+
__twWatchers[key].push(fn);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── DOM sync ──────────────────────────────────────────────────────────────────
|
|
57
|
+
function __twSync() {
|
|
58
|
+
// bind:value — sync input values
|
|
59
|
+
document.querySelectorAll('[data-tw-bind]').forEach(function(el) {
|
|
60
|
+
var key = el.getAttribute('data-tw-bind');
|
|
61
|
+
if (el !== document.activeElement && key in __twState) {
|
|
62
|
+
if (el.type === 'checkbox') {
|
|
63
|
+
el.checked = !!__twState[key];
|
|
64
|
+
} else {
|
|
65
|
+
el.value = __twState[key] !== null && __twState[key] !== undefined ? __twState[key] : '';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// show: — toggle visibility
|
|
71
|
+
document.querySelectorAll('[data-tw-show]').forEach(function(el) {
|
|
72
|
+
var expr = el.getAttribute('data-tw-show');
|
|
73
|
+
var val = __twEval(expr);
|
|
74
|
+
el.style.display = val ? '' : 'none';
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// tw-text — update text content
|
|
78
|
+
document.querySelectorAll('[data-tw-text]').forEach(function(el) {
|
|
79
|
+
var expr = el.getAttribute('data-tw-text');
|
|
80
|
+
var val = __twEval(expr);
|
|
81
|
+
el.textContent = val !== null && val !== undefined ? String(val) : '';
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// tw-html — update inner HTML (escaped)
|
|
85
|
+
document.querySelectorAll('[data-tw-html]').forEach(function(el) {
|
|
86
|
+
var expr = el.getAttribute('data-tw-html');
|
|
87
|
+
var val = __twEval(expr);
|
|
88
|
+
el.innerHTML = val !== null && val !== undefined ? String(val) : '';
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// class: — conditional class
|
|
92
|
+
document.querySelectorAll('[data-tw-class]').forEach(function(el) {
|
|
93
|
+
try {
|
|
94
|
+
var pairs = JSON.parse(el.getAttribute('data-tw-class'));
|
|
95
|
+
Object.keys(pairs).forEach(function(cls) {
|
|
96
|
+
el.classList.toggle(cls, !!__twEval(pairs[cls]));
|
|
97
|
+
});
|
|
98
|
+
} catch(e) {}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// tw-for — client-side list rendering
|
|
102
|
+
document.querySelectorAll('[data-tw-for]').forEach(function(container) {
|
|
103
|
+
var expr = container.getAttribute('data-tw-for');
|
|
104
|
+
var tmpl = container.querySelector('[data-tw-for-template]');
|
|
105
|
+
if (!tmpl) return;
|
|
106
|
+
var items = __twEval(expr);
|
|
107
|
+
if (!Array.isArray(items)) return;
|
|
108
|
+
var existing = container.querySelectorAll('[data-tw-for-item]');
|
|
109
|
+
existing.forEach(function(el) { el.remove(); });
|
|
110
|
+
items.forEach(function(item, idx) {
|
|
111
|
+
var clone = tmpl.cloneNode(true);
|
|
112
|
+
clone.removeAttribute('data-tw-for-template');
|
|
113
|
+
clone.setAttribute('data-tw-for-item', idx);
|
|
114
|
+
clone.style.display = '';
|
|
115
|
+
// Interpolate {item.*} / {item} in text nodes and attributes
|
|
116
|
+
__twInterpolateNode(clone, { item: item, index: idx });
|
|
117
|
+
container.appendChild(clone);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Safe expression evaluator ────────────────────────────────────────────────
|
|
123
|
+
function __twEval(expr) {
|
|
124
|
+
try {
|
|
125
|
+
var fn = new Function(
|
|
126
|
+
Object.keys(__twState).join(','),
|
|
127
|
+
'try { return (' + expr + '); } catch(e) { return undefined; }'
|
|
128
|
+
);
|
|
129
|
+
return fn.apply(null, Object.keys(__twState).map(function(k) { return __twState[k]; }));
|
|
130
|
+
} catch(e) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Text interpolation for tw-for clones ─────────────────────────────────────
|
|
136
|
+
function __twInterpolateNode(root, ctx) {
|
|
137
|
+
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
|
|
138
|
+
var node;
|
|
139
|
+
while ((node = walker.nextNode())) {
|
|
140
|
+
node.textContent = node.textContent.replace(/\{([^{}]+)\}/g, function(_, expr) {
|
|
141
|
+
try {
|
|
142
|
+
var fn = new Function(Object.keys(ctx).join(','), 'try{return('+expr+');}catch(e){return "";}');
|
|
143
|
+
var v = fn.apply(null, Object.keys(ctx).map(function(k){return ctx[k];}));
|
|
144
|
+
return v !== null && v !== undefined ? v : '';
|
|
145
|
+
} catch(e) { return ''; }
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
root.querySelectorAll('*').forEach(function(el) {
|
|
149
|
+
Array.from(el.attributes).forEach(function(attr) {
|
|
150
|
+
if (attr.name.startsWith('data-tw-')) return;
|
|
151
|
+
attr.value = attr.value.replace(/\{([^{}]+)\}/g, function(_, expr) {
|
|
152
|
+
try {
|
|
153
|
+
var fn = new Function(Object.keys(ctx).join(','), 'try{return('+expr+');}catch(e){return "";}');
|
|
154
|
+
var v = fn.apply(null, Object.keys(ctx).map(function(k){return ctx[k];}));
|
|
155
|
+
return v !== null && v !== undefined ? v : '';
|
|
156
|
+
} catch(e) { return ''; }
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Event binding ─────────────────────────────────────────────────────────────
|
|
163
|
+
function __twBindEvents() {
|
|
164
|
+
// bind:value -> two-way binding
|
|
165
|
+
document.querySelectorAll('[data-tw-bind]').forEach(function(el) {
|
|
166
|
+
if (el.__twBound) return;
|
|
167
|
+
el.__twBound = true;
|
|
168
|
+
var key = el.getAttribute('data-tw-bind');
|
|
169
|
+
var eventName = (el.type === 'checkbox' || el.tagName === 'SELECT') ? 'change' : 'input';
|
|
170
|
+
el.addEventListener(eventName, function() {
|
|
171
|
+
var val = el.type === 'checkbox' ? el.checked : el.value;
|
|
172
|
+
__twSet(key, val);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// on:* -> event handlers
|
|
177
|
+
document.querySelectorAll('[data-tw-on]').forEach(function(el) {
|
|
178
|
+
if (el.__twOnBound) return;
|
|
179
|
+
el.__twOnBound = true;
|
|
180
|
+
try {
|
|
181
|
+
var handlers = JSON.parse(el.getAttribute('data-tw-on'));
|
|
182
|
+
Object.keys(handlers).forEach(function(eventName) {
|
|
183
|
+
el.addEventListener(eventName, function(event) {
|
|
184
|
+
var expr = handlers[eventName];
|
|
185
|
+
try {
|
|
186
|
+
var fn = new Function(
|
|
187
|
+
Object.keys(__twState).concat(['event', '__twSet', '__twGet']).join(','),
|
|
188
|
+
expr
|
|
189
|
+
);
|
|
190
|
+
fn.apply(null, Object.keys(__twState).map(function(k) { return __twState[k]; }).concat([event, __twSet, __twGet]));
|
|
191
|
+
} catch(e) { console.error('TW event error:', e); }
|
|
192
|
+
__twSync();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
} catch(e) {}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// tw-ref
|
|
199
|
+
document.querySelectorAll('[data-tw-ref]').forEach(function(el) {
|
|
200
|
+
__twRefs[el.getAttribute('data-tw-ref')] = el;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Fetch helper for API calls ───────────────────────────────────────────────
|
|
205
|
+
window.__twFetch = async function(url, options) {
|
|
206
|
+
options = options || {};
|
|
207
|
+
var method = (options.method || 'GET').toUpperCase();
|
|
208
|
+
var headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {});
|
|
209
|
+
var body = options.body !== undefined ? JSON.stringify(options.body) : undefined;
|
|
210
|
+
try {
|
|
211
|
+
var res = await fetch(url, { method: method, headers: headers, body: body });
|
|
212
|
+
var ct = res.headers.get('content-type') || '';
|
|
213
|
+
var data = ct.includes('application/json') ? await res.json() : await res.text();
|
|
214
|
+
return { ok: res.ok, status: res.status, data: data };
|
|
215
|
+
} catch(e) {
|
|
216
|
+
return { ok: false, status: 0, data: null, error: e.message };
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
221
|
+
window.__tw = {
|
|
222
|
+
state: __twState,
|
|
223
|
+
set: __twSet,
|
|
224
|
+
get: __twGet,
|
|
225
|
+
watch: __twWatch,
|
|
226
|
+
sync: __twSync,
|
|
227
|
+
refs: __twRefs,
|
|
228
|
+
eval: __twEval,
|
|
229
|
+
fetch: __twFetch,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// ── Init on DOM ready ─────────────────────────────────────────────────────────
|
|
233
|
+
function __twInit() {
|
|
234
|
+
__twBindEvents();
|
|
235
|
+
__twSync();
|
|
236
|
+
// Re-bind after dynamic DOM changes (tw-for, etc.)
|
|
237
|
+
var observer = new MutationObserver(function() {
|
|
238
|
+
__twBindEvents();
|
|
239
|
+
});
|
|
240
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (document.readyState === 'loading') {
|
|
244
|
+
document.addEventListener('DOMContentLoaded', __twInit);
|
|
245
|
+
} else {
|
|
246
|
+
__twInit();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
})();
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
# ─── Compiler directive parsers ───────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
_STATE_BLOCK_RE = re.compile(r'\bstate\s*\{([^}]*)\}', re.DOTALL)
|
|
255
|
+
_STATE_KV_RE = re.compile(r'(\w+)\s+(.+?)(?=\n\s*\w|\Z)', re.DOTALL)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def parse_state_block(source: str) -> dict:
|
|
259
|
+
"""
|
|
260
|
+
Parse:
|
|
261
|
+
state {
|
|
262
|
+
count 0
|
|
263
|
+
name "hello"
|
|
264
|
+
items []
|
|
265
|
+
}
|
|
266
|
+
Returns {"count": 0, "name": "hello", "items": []}
|
|
267
|
+
"""
|
|
268
|
+
state = {}
|
|
269
|
+
for block_match in _STATE_BLOCK_RE.finditer(source):
|
|
270
|
+
body = block_match.group(1)
|
|
271
|
+
for kv in _STATE_KV_RE.finditer(body.strip()):
|
|
272
|
+
key = kv.group(1).strip()
|
|
273
|
+
raw = kv.group(2).strip()
|
|
274
|
+
from . import compiler as _c
|
|
275
|
+
val = _c.parse_literal_value(raw)
|
|
276
|
+
# Strip outer quotes from string values
|
|
277
|
+
if isinstance(val, str) and len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
|
|
278
|
+
val = val[1:-1]
|
|
279
|
+
state[key] = val
|
|
280
|
+
return state
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def has_reactivity(source: str) -> bool:
|
|
284
|
+
"""Quick check: does this .tw file use any reactive features?"""
|
|
285
|
+
patterns = [
|
|
286
|
+
r'\bstate\s*\{',
|
|
287
|
+
r'\bbind:',
|
|
288
|
+
r'\bon:',
|
|
289
|
+
r'\bshow:',
|
|
290
|
+
r'\btw-for\b',
|
|
291
|
+
r'\btw-text\b',
|
|
292
|
+
r'\btw-ref\b',
|
|
293
|
+
]
|
|
294
|
+
return any(re.search(p, source) for p in patterns)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def build_state_init_script(state: dict) -> str:
|
|
298
|
+
"""Generates the inline script that seeds __twDefineState."""
|
|
299
|
+
if not state:
|
|
300
|
+
return ""
|
|
301
|
+
return f"__twDefineState({json.dumps(state, ensure_ascii=False)});"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def get_reactivity_runtime_js() -> str:
|
|
305
|
+
return _TW_REACTIVE_RUNTIME
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ─── HTML attribute transformer ───────────────────────────────────────────────
|
|
309
|
+
# These convert .tw reactive directives to data-tw-* HTML attributes
|
|
310
|
+
# so the runtime JS can pick them up.
|
|
311
|
+
|
|
312
|
+
def transform_reactive_attrs(attrs: list) -> list:
|
|
313
|
+
"""
|
|
314
|
+
Convert:
|
|
315
|
+
bind:value "count" -> data-tw-bind="count"
|
|
316
|
+
show:visible "count > 0" -> data-tw-show="count > 0"
|
|
317
|
+
on:click "count++" -> data-tw-on='{"click":"count++"}'
|
|
318
|
+
tw-ref "myInput" -> data-tw-ref="myInput"
|
|
319
|
+
tw-text "count" -> data-tw-text="count"
|
|
320
|
+
"""
|
|
321
|
+
out = []
|
|
322
|
+
on_handlers = {}
|
|
323
|
+
for name, value in attrs:
|
|
324
|
+
nl = name.lower()
|
|
325
|
+
if nl.startswith("bind:"):
|
|
326
|
+
out.append(("data-tw-bind", value))
|
|
327
|
+
elif nl.startswith("show:"):
|
|
328
|
+
out.append(("data-tw-show", value))
|
|
329
|
+
elif nl.startswith("on:"):
|
|
330
|
+
event = nl[3:]
|
|
331
|
+
on_handlers[event] = value
|
|
332
|
+
elif nl in {"tw-ref", "tw:ref"}:
|
|
333
|
+
out.append(("data-tw-ref", value))
|
|
334
|
+
elif nl in {"tw-text", "tw:text"}:
|
|
335
|
+
out.append(("data-tw-text", value))
|
|
336
|
+
elif nl in {"tw-html", "tw:html"}:
|
|
337
|
+
out.append(("data-tw-html", value))
|
|
338
|
+
elif nl in {"tw-for", "tw:for"}:
|
|
339
|
+
out.append(("data-tw-for", value))
|
|
340
|
+
elif nl in {"tw-class", "tw:class"}:
|
|
341
|
+
# Expect JSON string: '{"active": "isActive", "hidden": "!show"}'
|
|
342
|
+
out.append(("data-tw-class", value))
|
|
343
|
+
else:
|
|
344
|
+
out.append((name, value))
|
|
345
|
+
|
|
346
|
+
if on_handlers:
|
|
347
|
+
out.append(("data-tw-on", json.dumps(on_handlers)))
|
|
348
|
+
|
|
349
|
+
return out
|