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.
Files changed (51) hide show
  1. tw_framework/__init__.py +13 -0
  2. tw_framework/advanced_diagnostics.py +25 -0
  3. tw_framework/asset_optimizer.py +30 -0
  4. tw_framework/ast_nodes.py +283 -0
  5. tw_framework/build.py +7 -0
  6. tw_framework/build_performance.py +26 -0
  7. tw_framework/build_report.py +84 -0
  8. tw_framework/cli.py +1258 -0
  9. tw_framework/code_splitting.py +76 -0
  10. tw_framework/common.py +22 -0
  11. tw_framework/compiler.py +4817 -0
  12. tw_framework/compiler_stats.py +51 -0
  13. tw_framework/dead_code.py +44 -0
  14. tw_framework/dependency_graph.py +91 -0
  15. tw_framework/deploy.py +7 -0
  16. tw_framework/dev.py +9 -0
  17. tw_framework/diagnostics.py +98 -0
  18. tw_framework/dynamic_imports.py +51 -0
  19. tw_framework/error_formatter.py +53 -0
  20. tw_framework/framework.py +3216 -0
  21. tw_framework/hydration.py +129 -0
  22. tw_framework/incremental_cache.py +53 -0
  23. tw_framework/interpreter.py +42 -0
  24. tw_framework/ir.py +171 -0
  25. tw_framework/lexer.py +52 -0
  26. tw_framework/lowering.py +68 -0
  27. tw_framework/parser.py +167 -0
  28. tw_framework/partial_rebuild.py +24 -0
  29. tw_framework/performance_analyzer.py +37 -0
  30. tw_framework/plugin_runtime.py +196 -0
  31. tw_framework/production_optimizer.py +82 -0
  32. tw_framework/reactivity.py +349 -0
  33. tw_framework/render_css.py +22 -0
  34. tw_framework/render_html.py +259 -0
  35. tw_framework/route_optimizer.py +40 -0
  36. tw_framework/router.py +7 -0
  37. tw_framework/runtime_model.py +23 -0
  38. tw_framework/runtime_values.py +31 -0
  39. tw_framework/semantic.py +162 -0
  40. tw_framework/server.py +532 -0
  41. tw_framework/signature.py +51 -0
  42. tw_framework/static_dynamic_auto.py +32 -0
  43. tw_framework/streaming.py +135 -0
  44. tw_framework/test_build_cli_pytest.py +1078 -0
  45. tw_framework/tree_shaking.py +48 -0
  46. tw_framework/twm_parser.py +274 -0
  47. tw_framework-0.2.3.dist-info/METADATA +8 -0
  48. tw_framework-0.2.3.dist-info/RECORD +51 -0
  49. tw_framework-0.2.3.dist-info/WHEEL +5 -0
  50. tw_framework-0.2.3.dist-info/entry_points.txt +2 -0
  51. tw_framework-0.2.3.dist-info/top_level.txt +1 -0
tw_framework/server.py ADDED
@@ -0,0 +1,532 @@
1
+ """
2
+ TW Production Server Runtime
3
+ - Real SSR (render server / render edge)
4
+ - Real API routes (production)
5
+ - Middleware (same as dev)
6
+ - Static file serving with ETag/Cache-Control
7
+ - Graceful shutdown, threading, keep-alive
8
+ """
9
+
10
+ import http.server
11
+ import logging
12
+ import mimetypes
13
+ import os
14
+ from collections import OrderedDict
15
+ import signal
16
+ import socketserver
17
+ import threading
18
+ import time
19
+ import urllib.parse
20
+ from typing import Dict, List, Optional, Tuple
21
+
22
+ from . import compiler
23
+ from .common import content_hash, log
24
+ from .framework import (
25
+ TWProject,
26
+ RouteMatch,
27
+ is_path_within,
28
+ normalize_url_path,
29
+ render_error_html,
30
+ format_compiler_error,
31
+ decode_request_body,
32
+ parse_cookie_header,
33
+ render_cookie_header,
34
+ load_project_env,
35
+ configure_compiler_paths,
36
+ invalidate_compiler_caches,
37
+ build_preview_candidates,
38
+ )
39
+
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ # ─── SSR Page Cache ───────────────────────────────────────────────────────────
45
+
46
+ class SSRCache:
47
+ """
48
+ In-memory TTL cache for server-rendered pages.
49
+ Respects `page { revalidate N }` — after N seconds, next request rebuilds.
50
+ """
51
+ def __init__(self, max_entries: int = 512):
52
+ # NOTE: This cache lives for the lifetime of the server process.
53
+ # Keep it bounded to avoid unbounded memory growth under many unique routes.
54
+ env_max = os.environ.get("TW_SSR_CACHE_MAX", "").strip()
55
+ if env_max:
56
+ try:
57
+ max_entries = int(env_max)
58
+ except ValueError:
59
+ logger.warning("Invalid TW_SSR_CACHE_MAX=%r; using default %d", env_max, max_entries)
60
+ self.max_entries = max(0, int(max_entries))
61
+ self._store: "OrderedDict[str, dict]" = OrderedDict()
62
+ self._lock = threading.Lock()
63
+
64
+ def get(self, key: str) -> Optional[bytes]:
65
+ with self._lock:
66
+ entry = self._store.get(key)
67
+ if not entry:
68
+ return None
69
+ if entry["ttl"] and (time.monotonic() - entry["at"]) > entry["ttl"]:
70
+ del self._store[key]
71
+ return None
72
+ # Mark as recently used (LRU)
73
+ self._store.move_to_end(key)
74
+ return entry["body"]
75
+
76
+ def _enforce_namespace_limit(self, namespace: Optional[str], namespace_max: Optional[int]):
77
+ if not namespace or not namespace_max or namespace_max <= 0:
78
+ return
79
+ while True:
80
+ matching_keys = [cache_key for cache_key, entry in self._store.items() if entry.get("namespace") == namespace]
81
+ if len(matching_keys) <= namespace_max:
82
+ return
83
+ self._store.pop(matching_keys[0], None)
84
+
85
+ def set(
86
+ self,
87
+ key: str,
88
+ body: bytes,
89
+ ttl: Optional[float],
90
+ *,
91
+ namespace: Optional[str] = None,
92
+ namespace_max: Optional[int] = None,
93
+ ):
94
+ with self._lock:
95
+ self._store[key] = {
96
+ "body": body,
97
+ "at": time.monotonic(),
98
+ "ttl": ttl,
99
+ "namespace": namespace,
100
+ }
101
+ self._store.move_to_end(key)
102
+ self._enforce_namespace_limit(namespace, namespace_max)
103
+ if self.max_entries and len(self._store) > self.max_entries:
104
+ # Evict least-recently-used entries
105
+ while self.max_entries and len(self._store) > self.max_entries:
106
+ self._store.popitem(last=False)
107
+
108
+ def invalidate(self, key: str):
109
+ with self._lock:
110
+ self._store.pop(key, None)
111
+
112
+ def clear(self):
113
+ with self._lock:
114
+ self._store.clear()
115
+
116
+
117
+ # ─── ETag / static file helpers ──────────────────────────────────────────────
118
+
119
+ def compute_etag(data: bytes) -> str:
120
+ return '"' + content_hash(data) + '"'
121
+
122
+
123
+ def serve_static_file(path: str) -> Optional[Tuple[bytes, str, str]]:
124
+ """Returns (body, content_type, etag) or None."""
125
+ if not os.path.isfile(path):
126
+ return None
127
+ with open(path, "rb") as f:
128
+ body = f.read()
129
+ content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
130
+ etag = compute_etag(body)
131
+ return body, content_type, etag
132
+
133
+
134
+ def try_brotli_or_gzip(path: str) -> Optional[Tuple[bytes, str]]:
135
+ """Returns (compressed_body, encoding) if pre-compressed variant exists."""
136
+ for variant, encoding in [(path + ".br", "br"), (path + ".gz", "gz")]:
137
+ if os.path.isfile(variant):
138
+ with open(variant, "rb") as f:
139
+ return f.read(), encoding
140
+ return None
141
+
142
+
143
+ # ─── Production Request Handler ──────────────────────────────────────────────
144
+
145
+ def make_production_handler(project: TWProject, output_dir: Optional[str], ssr_cache: SSRCache):
146
+
147
+ output_dir_abs = os.path.abspath(output_dir) if output_dir else None
148
+
149
+ class TWProductionHandler(http.server.BaseHTTPRequestHandler):
150
+ server_version = "TWServer/1.0"
151
+
152
+ def log_message(self, fmt, *args):
153
+ ts = time.strftime("%H:%M:%S")
154
+ log(f"[{ts}] {self.command} {self.path} — {fmt % args}")
155
+
156
+ def do_GET(self): self._handle("GET")
157
+ def do_POST(self): self._handle("POST")
158
+ def do_PUT(self): self._handle("PUT")
159
+ def do_PATCH(self): self._handle("PATCH")
160
+ def do_DELETE(self): self._handle("DELETE")
161
+ def do_OPTIONS(self):self._handle("OPTIONS")
162
+ def do_HEAD(self): self._handle("HEAD")
163
+
164
+ def _handle(self, method: str):
165
+ raw_path = self.path
166
+ url_path = normalize_url_path(raw_path)
167
+
168
+ # Health check
169
+ if url_path == "/__tw/health":
170
+ self._send(200, b"ok", "text/plain; charset=utf-8")
171
+ return
172
+
173
+ request_headers = dict(self.headers)
174
+
175
+ # ── Modern request middleware hook (extensions) ───────────────
176
+ hook_state = project.extensions.emit(
177
+ "beforeRequest",
178
+ method=method,
179
+ raw_path=raw_path,
180
+ url_path=url_path,
181
+ request_headers=request_headers,
182
+ request_meta={"client_ip": self.client_address[0] if self.client_address else ""},
183
+ dev_mode=False,
184
+ )
185
+ if hook_state.get("response"):
186
+ response = hook_state["response"]
187
+ self._send(
188
+ response["status"],
189
+ response["body"],
190
+ response["content_type"],
191
+ extra_headers=response.get("headers", []),
192
+ cookies=response.get("cookies", []),
193
+ )
194
+ return
195
+ if hook_state.get("redirect"):
196
+ location = str(hook_state["redirect"])
197
+ self._send(
198
+ 302, b"",
199
+ "text/plain; charset=utf-8",
200
+ extra_headers=[("Location", location)] + list(hook_state.get("headers", [])),
201
+ cookies=list(hook_state.get("cookies", [])),
202
+ )
203
+ return
204
+ if hook_state.get("rewrite"):
205
+ url_path = normalize_url_path(str(hook_state["rewrite"]))
206
+ request_headers = hook_state.get("request_headers", request_headers)
207
+
208
+ # ── Middleware ────────────────────────────────────────────────
209
+ mw = project.apply_middleware(
210
+ raw_path,
211
+ request_headers,
212
+ request_meta={"client_ip": self.client_address[0] if self.client_address else ""},
213
+ method=method,
214
+ )
215
+ if mw.get("response"):
216
+ response = mw["response"]
217
+ self._send(
218
+ response["status"],
219
+ response["body"],
220
+ response["content_type"],
221
+ extra_headers=response.get("headers", []),
222
+ cookies=response.get("cookies", []),
223
+ )
224
+ return
225
+ if mw.get("redirect"):
226
+ self._send(
227
+ 302, b"",
228
+ "text/plain; charset=utf-8",
229
+ extra_headers=[("Location", mw["redirect"])] + mw.get("headers", []),
230
+ cookies=mw.get("cookies", []),
231
+ )
232
+ return
233
+ url_path = normalize_url_path(mw.get("path", url_path))
234
+
235
+ # ── API routes ────────────────────────────────────────────────
236
+ api_route = project.resolve_api_route(url_path)
237
+ if api_route is not None:
238
+ body_data = decode_request_body(self) if method in {"POST", "PUT", "PATCH"} else {}
239
+ try:
240
+ resp = project.execute_api_route(api_route, method, raw_path, request_headers, body_data)
241
+ self._send(
242
+ resp["status"],
243
+ resp["body"],
244
+ resp["content_type"],
245
+ extra_headers=mw.get("headers", []) + resp.get("headers", []),
246
+ cookies=mw.get("cookies", []) + resp.get("cookies", []),
247
+ )
248
+ except Exception as err:
249
+ logger.exception("Unhandled API route error: %s %s -> %s", method, raw_path, api_route)
250
+ self._serve_500(f"API Error: {type(err).__name__}: {err}", mw)
251
+ return
252
+
253
+ # ── Static assets (assets/, _tw/static/chunks/) ───────────────
254
+ asset = project.resolve_asset(url_path)
255
+ if asset is not None:
256
+ payload, ct = asset
257
+ etag = compute_etag(payload)
258
+ if self.headers.get("If-None-Match") == etag:
259
+ self._send(304, b"", ct)
260
+ return
261
+ self._send(200, payload, ct,
262
+ extra_headers=[("ETag", etag), ("Cache-Control", "public, max-age=31536000, immutable")] + mw.get("headers", []),
263
+ cookies=mw.get("cookies", []))
264
+ return
265
+
266
+ # ── Pre-built static output (dist/) ───────────────────────────
267
+ if output_dir_abs and method in {"GET", "HEAD"}:
268
+ for candidate in build_preview_candidates(output_dir_abs, url_path):
269
+ candidate = os.path.abspath(candidate)
270
+ if not is_path_within(output_dir_abs, candidate):
271
+ continue
272
+ result = serve_static_file(candidate)
273
+ if result:
274
+ body_bytes, ct, etag = result
275
+ if self.headers.get("If-None-Match") == etag:
276
+ self._send(304, b"", ct)
277
+ return
278
+ # Prefer pre-compressed if client accepts
279
+ accept_enc = self.headers.get("Accept-Encoding", "")
280
+ compressed = try_brotli_or_gzip(candidate)
281
+ if compressed:
282
+ cbody, enc = compressed
283
+ if (enc == "br" and "br" in accept_enc) or (enc == "gz" and "gzip" in accept_enc):
284
+ enc_header = "br" if enc == "br" else "gzip"
285
+ self._send(200, cbody, ct,
286
+ extra_headers=[
287
+ ("ETag", etag),
288
+ ("Content-Encoding", enc_header),
289
+ ("Cache-Control", "public, max-age=3600"),
290
+ ("Vary", "Accept-Encoding"),
291
+ ] + mw.get("headers", []),
292
+ cookies=mw.get("cookies", []))
293
+ return
294
+ self._send(200, body_bytes if method != "HEAD" else b"", ct,
295
+ extra_headers=[("ETag", etag), ("Cache-Control", "public, max-age=3600")] + mw.get("headers", []),
296
+ cookies=mw.get("cookies", []))
297
+ return
298
+
299
+ # ── SSR / dynamic page ────────────────────────────────────────
300
+ match = project.resolve_route(url_path)
301
+ if not match:
302
+ self._serve_404(mw)
303
+ return
304
+
305
+ self._serve_page(match, method, mw, raw_path, request_headers)
306
+
307
+ def _build_page_cache_key(self, match: RouteMatch, raw_path: str, request_headers: Dict[str, str], render_mode: str, page_ast) -> str:
308
+ parsed = urllib.parse.urlparse(raw_path)
309
+ cache_by = getattr(page_ast, "cache_by", None)
310
+ if render_mode == "edge" and not cache_by:
311
+ cookie_hash = content_hash(request_headers.get("Cookie", "")) if request_headers.get("Cookie") else ""
312
+ query = parsed.query or ""
313
+ return f"{match.route_path}::{render_mode}::default::{query}::{cookie_hash}"
314
+ if not cache_by:
315
+ return f"{match.route_path}::{render_mode}"
316
+ selector = str(cache_by).strip()
317
+ if selector.startswith("cookie:"):
318
+ cookie_name = selector.split(":", 1)[1]
319
+ cookies = parse_cookie_header(request_headers.get("Cookie", ""))
320
+ return f"{match.route_path}::{render_mode}::{selector}::{cookies.get(cookie_name, '')}"
321
+ if selector.startswith("header:"):
322
+ header_name = selector.split(":", 1)[1]
323
+ return f"{match.route_path}::{render_mode}::{selector}::{request_headers.get(header_name, '')}"
324
+ if selector == "query":
325
+ return f"{match.route_path}::{render_mode}::{selector}::{parsed.query or ''}"
326
+ return f"{match.route_path}::{render_mode}::{selector}"
327
+
328
+ def _serve_page(self, match: RouteMatch, method: str, mw: dict, raw_path: str, request_headers: Dict[str, str]):
329
+ page_path = match.page_info["path"]
330
+ try:
331
+ page_ast = compiler.load_page_ast_from_file(page_path)
332
+ except Exception as err:
333
+ self._serve_500(format_compiler_error(page_path, err), mw)
334
+ return
335
+
336
+ render_mode = getattr(page_ast, "render_mode", "static")
337
+ revalidate_ttl = getattr(page_ast, "revalidate", None)
338
+ if revalidate_ttl is not None:
339
+ try:
340
+ revalidate_ttl = float(revalidate_ttl)
341
+ except (TypeError, ValueError):
342
+ logger.exception("Invalid `revalidate` value in %s: %r", page_path, revalidate_ttl)
343
+ revalidate_ttl = None
344
+
345
+ cache_key = self._build_page_cache_key(match, raw_path, request_headers, render_mode, page_ast)
346
+ cache_size = getattr(page_ast, "cache_size", None)
347
+ try:
348
+ cache_size = int(cache_size) if cache_size is not None else None
349
+ except (TypeError, ValueError):
350
+ cache_size = None
351
+
352
+ # Static pages: try SSR cache first
353
+ if render_mode in {"static", "edge"}:
354
+ cached = ssr_cache.get(cache_key)
355
+ if cached is not None:
356
+ self._send(200, cached, "text/html; charset=utf-8",
357
+ extra_headers=[("X-TW-Cache", "HIT"), ("X-TW-Render", render_mode)] + mw.get("headers", []),
358
+ cookies=mw.get("cookies", []))
359
+ return
360
+
361
+ try:
362
+ response = project.compile_match_response(match, dev_mode=False)
363
+ except Exception as err:
364
+ self._serve_500(format_compiler_error(page_path, err), mw)
365
+ return
366
+
367
+ body_bytes = response["html"].encode("utf-8")
368
+ status = response.get("status", 200)
369
+ page_headers = response.get("headers", [])
370
+
371
+ # Cache rendered output for static/edge pages with revalidate
372
+ if render_mode in {"static", "edge"} and revalidate_ttl:
373
+ ssr_cache.set(
374
+ cache_key,
375
+ body_bytes,
376
+ revalidate_ttl,
377
+ namespace=match.route_path,
378
+ namespace_max=cache_size,
379
+ )
380
+ elif render_mode == "static":
381
+ # Static pages without revalidate: cache indefinitely (until server restart)
382
+ ssr_cache.set(
383
+ cache_key,
384
+ body_bytes,
385
+ None,
386
+ namespace=match.route_path,
387
+ namespace_max=cache_size,
388
+ )
389
+
390
+ self._send(
391
+ status,
392
+ body_bytes if self.command != "HEAD" else b"",
393
+ "text/html; charset=utf-8",
394
+ extra_headers=[
395
+ ("X-TW-Cache", "MISS"),
396
+ ("X-TW-Render", render_mode),
397
+ ] + mw.get("headers", []) + page_headers,
398
+ cookies=mw.get("cookies", []),
399
+ )
400
+
401
+ def _serve_404(self, mw: dict):
402
+ try:
403
+ custom = project.compile_special_page(404, dev_mode=False)
404
+ if custom:
405
+ self._send(404, custom.encode("utf-8"), "text/html; charset=utf-8",
406
+ extra_headers=mw.get("headers", []), cookies=mw.get("cookies", []))
407
+ return
408
+ except Exception:
409
+ logger.exception("Failed to compile custom 404 page")
410
+ self._send(404, render_error_html("Not Found", f"Route not found: {normalize_url_path(self.path)}", 404),
411
+ "text/html; charset=utf-8", extra_headers=mw.get("headers", []), cookies=mw.get("cookies", []))
412
+
413
+ def _serve_500(self, message: str, mw: dict):
414
+ try:
415
+ custom = project.compile_special_page(500, dev_mode=False)
416
+ if custom:
417
+ self._send(500, custom.encode("utf-8"), "text/html; charset=utf-8",
418
+ extra_headers=mw.get("headers", []), cookies=mw.get("cookies", []))
419
+ return
420
+ except Exception:
421
+ logger.exception("Failed to compile custom 500 page")
422
+ self._send(
423
+ 500,
424
+ render_error_html("Server Error", message, 500),
425
+ "text/html; charset=utf-8",
426
+ extra_headers=mw.get("headers", []),
427
+ cookies=mw.get("cookies", []),
428
+ )
429
+
430
+ def _send(self, status: int, body: bytes, content_type: str,
431
+ extra_headers: Optional[List] = None,
432
+ cookies: Optional[List] = None):
433
+ self.send_response(status)
434
+ self.send_header("Content-Type", content_type)
435
+ self.send_header("Content-Length", str(len(body)))
436
+ for name, value in (extra_headers or []):
437
+ self.send_header(name, value)
438
+ for name, value in (cookies or []):
439
+ self.send_header(
440
+ "Set-Cookie",
441
+ render_cookie_header(
442
+ name,
443
+ value,
444
+ config=project.config,
445
+ request_headers=dict(self.headers),
446
+ server_port=self.server.server_address[1],
447
+ ),
448
+ )
449
+ self.end_headers()
450
+ if body:
451
+ try:
452
+ self.wfile.write(body)
453
+ except (BrokenPipeError, ConnectionResetError):
454
+ pass
455
+
456
+ return TWProductionHandler
457
+
458
+
459
+ # ─── Threaded TCP server ──────────────────────────────────────────────────────
460
+
461
+ class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
462
+ allow_reuse_address = True
463
+ daemon_threads = True
464
+
465
+
466
+ # ─── run_production_server ────────────────────────────────────────────────────
467
+
468
+ def run_production_server(
469
+ project_root: str,
470
+ host: str = "0.0.0.0",
471
+ port: int = 8000,
472
+ output_dir: Optional[str] = None,
473
+ workers: Optional[int] = None,
474
+ ):
475
+ """
476
+ Start the TW production server.
477
+
478
+ - Serves SSR pages (render static | server | edge)
479
+ - Handles real API routes
480
+ - Applies middleware
481
+ - Serves pre-built static files from output_dir if provided
482
+ - ETag, Cache-Control, brotli/gzip negotiation
483
+ - Graceful SIGTERM/SIGINT shutdown
484
+ """
485
+ project_root = os.path.abspath(project_root)
486
+ configure_compiler_paths(project_root)
487
+ invalidate_compiler_caches()
488
+ load_project_env(project_root, "production")
489
+
490
+ project = TWProject(project_root)
491
+ config_cache_max = (
492
+ compiler.get_config_value(project.config, "ssr", "cache_max")
493
+ or project.config.get("ssr.cache_max")
494
+ or project.config.get("ssr_cache_max")
495
+ )
496
+ ssr_cache = SSRCache(max_entries=int(config_cache_max) if config_cache_max is not None else 512)
497
+
498
+ handler = make_production_handler(project, output_dir, ssr_cache)
499
+
500
+ try:
501
+ server = ThreadedTCPServer((host, port), handler)
502
+ except OSError as err:
503
+ raise RuntimeError(
504
+ f"Could not bind to port {port}: {err}\n"
505
+ f"Try: TW_PORT={port + 1} tw serve"
506
+ ) from err
507
+
508
+ log("🚀 TW Production Server")
509
+ log(f" Listening: http://{host}:{port}")
510
+ log(f" Project: {project_root}")
511
+ if output_dir and os.path.isdir(output_dir):
512
+ log(f" Static: {os.path.abspath(output_dir)}")
513
+ log(" SSR cache: enabled")
514
+ log(" Press Ctrl+C to stop\n")
515
+
516
+ stop_event = threading.Event()
517
+
518
+ def _shutdown(signum, frame):
519
+ log("\nShutting down...")
520
+ stop_event.set()
521
+ threading.Thread(target=server.shutdown, daemon=True).start()
522
+
523
+ signal.signal(signal.SIGTERM, _shutdown)
524
+ signal.signal(signal.SIGINT, _shutdown)
525
+
526
+ try:
527
+ server.serve_forever()
528
+ except KeyboardInterrupt:
529
+ pass
530
+ finally:
531
+ server.server_close()
532
+ log("Server stopped.")
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .common import content_hash
7
+
8
+ TW_SIGNATURE_SALT = "TW-FRAMEWORK-V1"
9
+
10
+
11
+ def compute_tw_signature(program: Any, *, length: int = 16) -> str:
12
+ """
13
+ किसी भी TW program (AST या IR, दोनों में .to_dict() होता है) का
14
+ deterministic fingerprint निकालता है।
15
+
16
+ यह कोई गुप्त secret नहीं है — कोई भी जिसके पास वही `.tw` source और
17
+ वही TW framework version है, वो यही signature दोबारा बना सकता है।
18
+ यही तो इसका मकसद है: इससे कोई भी `tw verify` चलाकर स्वतंत्र रूप से
19
+ यह पुष्टि कर सकता है कि दिया गया HTML असल में किसी specific TW source
20
+ को compile करने से आया है, न कि कोई हाथ से लिखा हुआ HTML जो सिर्फ
21
+ "TW से बना है" का दावा करता है।
22
+ """
23
+ try:
24
+ payload = program.to_dict()
25
+ except Exception:
26
+ payload = {"repr": repr(program)}
27
+ serialized = json.dumps(payload, sort_keys=True, default=str, ensure_ascii=False)
28
+ return content_hash(f"{TW_SIGNATURE_SALT}::{serialized}", length=length)
29
+
30
+
31
+ def build_signature_banner(signature: str, *, title: str = "") -> str:
32
+ safe_title = (title or "").replace("-->", "-- >")
33
+ return (
34
+ f"<!-- Built with TW Framework (https://github.com/ffakraj-ui/twlang) "
35
+ f"- tw-signature:{signature} -->"
36
+ )
37
+
38
+
39
+ def build_signature_meta_tag(signature: str) -> str:
40
+ return (
41
+ f'<meta name="generator" content="TW Framework">\n'
42
+ f'<meta name="tw-signature" content="{signature}">'
43
+ )
44
+
45
+
46
+ __all__ = [
47
+ "compute_tw_signature",
48
+ "build_signature_banner",
49
+ "build_signature_meta_tag",
50
+ "TW_SIGNATURE_SALT",
51
+ ]
@@ -0,0 +1,32 @@
1
+ """
2
+ Automatic static/dynamic route detection for TW Framework.
3
+
4
+ When `render auto` is set, the compiler chooses the best rendering mode
5
+ based on the page's content and dependencies.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+ from . import compiler
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def determine_render_mode(page_path: str) -> str:
17
+ """Analyze a page and return the recommended render mode."""
18
+ raw = compiler.read_text_file(page_path)
19
+ # Check for explicit render mode
20
+ import re
21
+ match = re.search(r'render\s+(\w+)', raw)
22
+ if match:
23
+ mode = match.group(1).lower()
24
+ if mode in ("static", "server", "edge"):
25
+ return mode
26
+ # Auto-detect: if the page has dynamic content (e.g., `for`, `if`, `state`), use server
27
+ if "state {" in raw or "for " in raw or "if " in raw:
28
+ return "server"
29
+ return "static"
30
+
31
+
32
+ __all__ = ["determine_render_mode"]