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
@@ -0,0 +1,4817 @@
1
+ import os
2
+ import re
3
+ import json
4
+ import ast
5
+ import copy
6
+ import shutil
7
+ import hashlib
8
+ import difflib
9
+ import argparse
10
+ import logging
11
+ import contextlib
12
+ import tempfile
13
+ import threading
14
+ import concurrent.futures
15
+ import unicodedata
16
+ import errno
17
+ from collections import OrderedDict
18
+ from dataclasses import dataclass
19
+
20
+ from .common import content_hash, log
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Cross-process file locks (best-effort). This prevents concurrent `tw build`
25
+ # processes from corrupting shared JSON artifacts (manifest/graphs) mid-write.
26
+ try:
27
+ import fcntl # type: ignore
28
+ except ImportError: # pragma: no cover
29
+ fcntl = None
30
+
31
+
32
+ @contextlib.contextmanager
33
+ def _file_lock(target_path: str, *, shared: bool):
34
+ lock_path = str(target_path) + ".lock"
35
+ os.makedirs(os.path.dirname(lock_path), exist_ok=True)
36
+ if fcntl is None:
37
+ yield
38
+ return
39
+ with open(lock_path, "w", encoding="utf-8") as lock_file:
40
+ # Some filesystems (notably Android shared storage via FUSE /sdcard)
41
+ # don't implement POSIX file locks and raise ENOSYS/EOPNOTSUPP.
42
+ # In that case, fall back to a no-op lock (best-effort).
43
+ locked = False
44
+ try:
45
+ fcntl.flock(lock_file, fcntl.LOCK_SH if shared else fcntl.LOCK_EX)
46
+ locked = True
47
+ except OSError as e:
48
+ if e.errno not in {errno.ENOSYS, errno.EOPNOTSUPP, errno.EINVAL}:
49
+ raise
50
+ try:
51
+ yield
52
+ finally:
53
+ if locked:
54
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
55
+
56
+ # Reactivity module (lazy import to avoid circular)
57
+ _reactivity = None
58
+ def _get_reactivity():
59
+ global _reactivity
60
+ if _reactivity is None:
61
+ from . import reactivity as _reactivity
62
+ return _reactivity
63
+
64
+
65
+ PROJECT_ROOT = "../MySite"
66
+ HOME_DIR = os.path.join(PROJECT_ROOT, "[home]")
67
+ COMPONENTS_DIR = os.path.join(HOME_DIR, "components")
68
+ PAGES_DIR = os.path.join(HOME_DIR, "pages")
69
+ ASSETS_DIR = os.path.join(HOME_DIR, "assets")
70
+ LAYOUTS_DIR = os.path.join(HOME_DIR, "layouts")
71
+ API_DIR = os.path.join(HOME_DIR, "api")
72
+ INDEX_FILE = os.path.join(HOME_DIR, "index.tw")
73
+ STYLE_FILE = os.path.join(HOME_DIR, "style.tss")
74
+ CONFIG_FILE = os.path.join(PROJECT_ROOT, "tw.config")
75
+
76
+ INTERNAL_DIR = os.path.join(PROJECT_ROOT, ".tw")
77
+ CACHE_DIR = os.path.join(INTERNAL_DIR, "cache")
78
+ MANIFEST_DIR = os.path.join(INTERNAL_DIR, "manifest")
79
+ COMPILER_DIR = os.path.join(INTERNAL_DIR, "compiler")
80
+
81
+ PUBLIC_DIR = os.path.join(PROJECT_ROOT, "dist")
82
+ BUILD_DIR = PUBLIC_DIR
83
+ PUBLIC_ASSETS_DIR = os.path.join(PUBLIC_DIR, "assets")
84
+ CHUNKS_DIR = os.path.join(COMPILER_DIR, "chunks")
85
+ CHUNKS_PUBLIC_DIR = os.path.join(PUBLIC_DIR, "_tw", "static", "chunks")
86
+ CHUNKS_URL_PREFIX = "/_tw/static/chunks/"
87
+ BUILD_MANIFEST_FILE = os.path.join(MANIFEST_DIR, "build-manifest.json")
88
+ HASH_DB_FILE = os.path.join(CACHE_DIR, "hash-db.json")
89
+ DEPENDENCY_GRAPH_FILE = os.path.join(CACHE_DIR, "dependency-graph.json")
90
+ DEFAULT_WORKERS = max(1, min(32, os.cpu_count() or 1))
91
+ MINIFY_OUTPUT = False
92
+ CURRENT_ENV_NAME = "development"
93
+ ASSET_URL_MAP = {}
94
+ BUILD_MANIFEST_VERSION = 2
95
+ DEPENDENCY_GRAPH_VERSION = 2
96
+ # Folder route groups: `(marketing)` should be ignored in routes
97
+ ROUTE_GROUP_DIR_RE = re.compile(r"^\(.*\)$")
98
+
99
+ VOID_TAGS = {
100
+ "img", "input", "hr", "br", "meta", "link", "col",
101
+ "embed", "source", "track", "wbr", "area", "base",
102
+ }
103
+
104
+ BOOLEAN_ATTRS = {
105
+ "checked", "disabled", "controls", "selected", "required",
106
+ "readonly", "multiple", "autofocus", "autoplay", "loop",
107
+ "muted", "hidden", "open",
108
+ }
109
+
110
+ CSS_PROPERTIES = {
111
+ "display", "position", "top", "right", "bottom", "left",
112
+ "float", "clear", "overflow", "overflow-x", "overflow-y",
113
+ "z-index", "visibility",
114
+ "width", "height", "min-width", "max-width", "min-height", "max-height",
115
+ "margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
116
+ "padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
117
+ "border", "border-top", "border-right", "border-bottom", "border-left",
118
+ "border-width", "border-style", "border-color", "border-radius",
119
+ "box-sizing", "box-shadow", "outline",
120
+ "background", "background-color", "background-image", "background-size",
121
+ "background-position", "background-repeat", "background-attachment",
122
+ "color", "font", "font-size", "font-family", "font-weight", "font-style",
123
+ "font-variant", "line-height", "letter-spacing", "word-spacing",
124
+ "text-align", "text-decoration", "text-transform", "text-shadow",
125
+ "white-space", "word-break", "word-wrap",
126
+ "flex", "flex-direction", "flex-wrap", "flex-flow",
127
+ "justify-content", "align-items", "align-self", "align-content",
128
+ "flex-grow", "flex-shrink", "flex-basis", "order", "gap",
129
+ "grid", "grid-template", "grid-template-columns", "grid-template-rows",
130
+ "grid-column", "grid-row", "grid-gap", "column-gap", "row-gap",
131
+ "transition", "animation", "transform", "opacity", "cursor",
132
+ "list-style", "pointer-events", "user-select", "content",
133
+ "radius", "shadow", "bg",
134
+ }
135
+
136
+ CSS_ALIASES = {
137
+ "radius": "border-radius",
138
+ "shadow": "box-shadow",
139
+ "font": "font-size",
140
+ "bg": "background",
141
+ }
142
+
143
+ NUMERIC_CSS = {
144
+ "border-radius", "padding", "padding-top", "padding-right",
145
+ "padding-bottom", "padding-left", "margin", "margin-top",
146
+ "margin-right", "margin-bottom", "margin-left",
147
+ "width", "height", "min-width", "max-width", "min-height", "max-height",
148
+ "font-size", "gap", "column-gap", "row-gap", "top", "right", "bottom",
149
+ "left", "border-width", "letter-spacing", "word-spacing", "line-height",
150
+ }
151
+
152
+ HTML_ATTRIBUTES = {
153
+ "id", "class", "href", "src", "alt", "type", "name", "value",
154
+ "placeholder", "action", "method", "target", "rel", "title",
155
+ "for", "rows", "cols", "colspan", "rowspan", "tabindex",
156
+ "aria-label", "aria-hidden", "aria-describedby", "role",
157
+ "checked", "disabled", "selected", "required", "readonly",
158
+ "multiple", "autofocus", "autoplay", "loop", "muted", "controls",
159
+ "hidden", "open", "spellcheck", "autocomplete", "enctype",
160
+ "min", "max", "step", "pattern", "accept", "loading", "decoding",
161
+ "fetchpriority", "width", "height", "sizes", "srcset",
162
+ }
163
+
164
+ EVENTS = {
165
+ "click", "dblclick", "change", "input", "submit", "focus", "blur",
166
+ "keydown", "keyup", "keypress", "mouseover", "mouseout",
167
+ "mouseenter", "mouseleave", "mousedown", "mouseup",
168
+ "load", "resize", "scroll", "contextmenu",
169
+ }
170
+
171
+ ROUTER_KEYS = {"link", "goto"}
172
+
173
+ NUM_RE = re.compile(r"^-?\d+(\.\d+)?$")
174
+ # Template placeholders: `{brandName}` (single braces)
175
+ INTERPOLATION_RE = re.compile(r"\{([^{}]+)\}")
176
+ SCRIPT_PLACEHOLDER_RE = re.compile(r"^__TWSCRIPT(\d+)__$")
177
+ TWM_SCRIPT_PLACEHOLDER_RE = re.compile(r"^__TWTWM(\d+)__$")
178
+ TAG_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
179
+ DYNAMIC_FILE_RE = re.compile(r"^\[(\w+)\]\.tw$")
180
+ CATCH_ALL_FILE_RE = re.compile(r"^\[\.\.\.(\w+)\]\.tw$")
181
+ OPTIONAL_CATCH_ALL_FILE_RE = re.compile(r"^\[\[\.\.\.(\w+)\]\]\.tw$")
182
+
183
+ INLINE_SCRIPTS = {}
184
+ INLINE_TWM_SCRIPTS = {}
185
+ _SCRIPT_COUNTER = 0
186
+ _TWM_SCRIPT_COUNTER = 0
187
+ # Chunk URL cache: digest -> public URL.
188
+ # Keep this bounded in long-lived dev/serve sessions to avoid unbounded memory.
189
+ _CHUNK_CACHE: "OrderedDict[str, str]" = OrderedDict()
190
+ _CHUNK_CACHE_MAX_DEFAULT = 2048
191
+ _COMPONENT_AST_CACHE = {}
192
+ _COMPONENT_EXISTS_CACHE = {}
193
+ _COMPONENT_PATH_CACHE = {}
194
+ _LAYOUT_CACHE = {}
195
+ _LAYOUT_META_CACHE = {}
196
+ _COMPONENT_DEP_GRAPH_CACHE = {}
197
+ _COMPONENT_STYLESHEET_PATHS = {}
198
+ _CHUNK_LOCK = threading.Lock()
199
+ _SCRIPT_LOCK = threading.Lock()
200
+ # Single coarse lock for shared compiler caches used by ThreadPoolExecutor workers.
201
+ _CACHE_LOCK = threading.RLock()
202
+
203
+ # Layout-level directives (layouts are treated as raw HTML templates, so we scan & strip these lines)
204
+ LAYOUT_RESPONSIVE_RE = re.compile(
205
+ r"(?m)^\s*tw@responsive\s*(?:=\s*)?(true|false|\"true\"|\"false\"|'true'|'false')\s*$"
206
+ )
207
+
208
+ IMPORT_RE = re.compile(r'\bimport\s+"([^"]+)"')
209
+ LAYOUT_RE = re.compile(r'\blayout\s+(?:"([^"]+)"|([^\s{}]+))')
210
+ LOAD_RE = re.compile(r'\bload\s+(?:"([^"]+)"|(@[^\s{}"\']+))')
211
+ COMPONENT_LOAD_RE = re.compile(r'(?m)^[ \t]*load\s+(?:"([^"]+)"|(@[^\s{}"\']+))[ \t]*$')
212
+ LAYOUT_LOAD_RE = re.compile(r'(?m)^[ \t]*load\s+(?:"([^"]+)"|(@[^\s{}"\']+))[ \t]*$')
213
+
214
+
215
+ @dataclass
216
+ class BuildOptions:
217
+ force: bool = False
218
+ workers: int = DEFAULT_WORKERS
219
+
220
+
221
+ @dataclass
222
+ class Token:
223
+ type: str
224
+ value: str
225
+ line: int
226
+ col: int
227
+
228
+
229
+ @dataclass
230
+ class Diagnostic:
231
+ """Unified diagnostic for TW Framework."""
232
+ severity: str = "error"
233
+ code: str = ""
234
+ message: str = ""
235
+ file_path: str = ""
236
+ line: int = 0
237
+ col: int = 0
238
+ end_line: int = 0
239
+ end_col: int = 0
240
+ suggestion: str = ""
241
+ notes: list = None
242
+ phase: str = ""
243
+ exception_type: str = ""
244
+ # New fields for enhanced diagnostics
245
+ category: str = "compiler"
246
+ relative_path: str = ""
247
+ absolute_path: str = ""
248
+ source_snippet: str = ""
249
+ highlight: str = ""
250
+ reason: str = ""
251
+ expected: str = ""
252
+ found: str = ""
253
+ why: str = ""
254
+ doc_link: str = ""
255
+ parser_state: str = ""
256
+ traceback: str = ""
257
+
258
+ def __post_init__(self):
259
+ if not self.relative_path and self.file_path:
260
+ try:
261
+ self.relative_path = os.path.relpath(self.file_path, PROJECT_ROOT)
262
+ except (ValueError, OSError):
263
+ self.relative_path = self.file_path
264
+ if not self.absolute_path and self.file_path:
265
+ self.absolute_path = os.path.abspath(self.file_path)
266
+ if not self.reason and self.message:
267
+ self.reason = self.message
268
+ if not self.code:
269
+ self.code = "TW0000"
270
+ if self.notes is None:
271
+ self.notes = []
272
+
273
+ def to_new_diagnostic(self):
274
+ """Convert to the new diagnostics.Diagnostic for unified formatting."""
275
+ from .diagnostics import Diagnostic as NewDiag
276
+ return NewDiag(
277
+ id=self.code,
278
+ category=self.category,
279
+ severity=self.severity,
280
+ relative_path=self.relative_path,
281
+ absolute_path=self.absolute_path,
282
+ line=self.line,
283
+ col=self.col,
284
+ source_snippet=self.source_snippet,
285
+ highlight=self.highlight,
286
+ reason=self.reason,
287
+ expected=self.expected,
288
+ found=self.found,
289
+ why=self.why,
290
+ doc_link=self.doc_link,
291
+ parser_state=self.parser_state,
292
+ exception_type=self.exception_type,
293
+ traceback=self.traceback,
294
+ )
295
+
296
+
297
+ class CompilerError(Exception):
298
+ def __init__(self, message, token=None, file_path=None, suggestion=None, code="TW1000", notes=None, category="parser", parser_state=""):
299
+ super().__init__(message)
300
+ self.message = message
301
+ self.token = token
302
+ self.file_path = file_path
303
+ self.suggestion = suggestion
304
+ self.code = code
305
+ self.notes = list(notes or [])
306
+ self.category = category
307
+ self.parser_state = parser_state
308
+
309
+ def to_diagnostic(self, fallback_file_path=None):
310
+ line = getattr(self.token, "line", 0) or 0
311
+ col = getattr(self.token, "col", 0) or 0
312
+ return Diagnostic(
313
+ severity="error",
314
+ code=self.code or "TW1000",
315
+ message=self.message,
316
+ file_path=self.file_path or fallback_file_path or "",
317
+ line=line,
318
+ col=col,
319
+ end_line=line,
320
+ end_col=col,
321
+ suggestion=self.suggestion,
322
+ notes=list(self.notes or []),
323
+ category=self.category,
324
+ parser_state=self.parser_state,
325
+ )
326
+
327
+
328
+ class DiagnosticEmitter:
329
+ """Formats diagnostics using the unified engine."""
330
+
331
+ def __init__(self, file_path, source):
332
+ self.file_path = file_path
333
+ self.source = source
334
+ self.lines = source.splitlines()
335
+
336
+ def format(self, err, debug: bool = False):
337
+ from .diagnostics import Diagnostic as NewDiagnostic, format_advanced_error
338
+
339
+ if isinstance(err, Diagnostic):
340
+ diag = err
341
+ elif isinstance(err, CompilerError):
342
+ diag = err.to_diagnostic(self.file_path)
343
+ else:
344
+ diag = Diagnostic(
345
+ severity="error",
346
+ code="TW0000",
347
+ message=str(err),
348
+ file_path=self.file_path,
349
+ )
350
+
351
+ if diag.line and self.lines and diag.line <= len(self.lines):
352
+ line_text = self.lines[diag.line - 1]
353
+ diag.source_snippet = f"{diag.line:>4} | {line_text}"
354
+ gutter_len = len(f"{diag.line:>4} | ")
355
+ diag.highlight = " " * (gutter_len + max(diag.col - 1, 0)) + "^"
356
+
357
+ new_diag = NewDiagnostic.from_legacy(diag)
358
+ return format_advanced_error(new_diag, project_root=PROJECT_ROOT)
359
+
360
+
361
+ def print_diagnostic(diagnostic, debug: bool = False):
362
+ path = diagnostic.file_path or ""
363
+ source = ""
364
+ if path and os.path.exists(path):
365
+ try:
366
+ source = read_text_file(path)
367
+ except (OSError, UnicodeDecodeError):
368
+ source = ""
369
+ emitter = DiagnosticEmitter(path, source)
370
+ log(emitter.format(diagnostic, debug=debug), level=diagnostic.severity)
371
+
372
+
373
+ def _mojibake_score(text):
374
+ return sum(text.count(ch) for ch in ("Ã", "Â", "â", "ð"))
375
+
376
+
377
+ def _repair_common_mojibake(text):
378
+ """
379
+ Best-effort repair for UTF-8 text that was previously decoded as latin-1/cp1252
380
+ and then saved again as plain text, for example:
381
+ - `•` -> `•`
382
+ - `—` -> `—`
383
+ - `🚀` -> `🚀`
384
+ """
385
+ if not text or not any(ch in text for ch in ("Ã", "Â", "â", "ð")):
386
+ return text
387
+ candidates = []
388
+ for encoding in ("cp1252", "latin-1"):
389
+ try:
390
+ candidates.append(text.encode(encoding).decode("utf-8"))
391
+ except UnicodeError:
392
+ continue
393
+ if not candidates:
394
+ return text
395
+ repaired = min(candidates, key=_mojibake_score)
396
+ return repaired if _mojibake_score(repaired) < _mojibake_score(text) else text
397
+
398
+
399
+ def normalize_source_text(text):
400
+ if not text:
401
+ return text
402
+ text = text.lstrip("\ufeff")
403
+ text = unicodedata.normalize("NFC", text)
404
+ return _repair_common_mojibake(text)
405
+
406
+
407
+ def read_text_file(path):
408
+ with open(path, "r", encoding="utf-8") as f:
409
+ return normalize_source_text(f.read())
410
+
411
+
412
+ def get_incremental_cache(project_root: str = PROJECT_ROOT):
413
+ """Return an IncrementalCache instance for the given project."""
414
+ from .incremental_cache import IncrementalCache
415
+ return IncrementalCache(project_root)
416
+
417
+
418
+ def get_dependency_graph(project_root: str = PROJECT_ROOT):
419
+ """Return a DependencyGraph instance for the given project."""
420
+ from .dependency_graph import DependencyGraph
421
+ return DependencyGraph(project_root)
422
+
423
+
424
+ def get_framework_version() -> str:
425
+ """Return the current TW Framework version string."""
426
+ return "1.0.0"
427
+
428
+
429
+ def detect_tw_project(root: str) -> bool:
430
+ """Check if the given directory contains a TW Framework project."""
431
+ config_path = os.path.join(root, "tw.config")
432
+ return os.path.isfile(config_path)
433
+
434
+
435
+ def normalize_path(path):
436
+ return os.path.normpath(path)
437
+
438
+
439
+ def resolve_source_path(path, base_dir):
440
+ value = str(path or "")
441
+ if value.startswith("@./"):
442
+ value = value[3:]
443
+ return normalize_path(os.path.join(HOME_DIR, value))
444
+ if value.startswith("@../"):
445
+ value = value[1:]
446
+ return normalize_path(os.path.join(base_dir, value))
447
+ project_relative = value.startswith("@")
448
+ if value.startswith("@"):
449
+ value = value[1:]
450
+ if os.path.isabs(value) or re.match(r"^[A-Za-z]:[\\/]", value):
451
+ return normalize_path(value)
452
+ if project_relative:
453
+ return normalize_path(os.path.join(PROJECT_ROOT, value))
454
+ return normalize_path(os.path.join(base_dir, value))
455
+
456
+
457
+ def minify_html_content(text):
458
+ # Collapse whitespace between tags: `> <` -> `><`
459
+ text = re.sub(r">\s+<", "><", text)
460
+ text = re.sub(r"\n\s*\n+", "\n", text)
461
+ return text.strip()
462
+
463
+
464
+ def minify_css_content(text):
465
+ # Strip block comments: /* ... */
466
+ text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
467
+ # Collapse runs of whitespace
468
+ text = re.sub(r"\s+", " ", text)
469
+ # Remove whitespace around common separators
470
+ text = re.sub(r"\s*([{}:;,])\s*", r"\1", text)
471
+ return text.strip()
472
+
473
+
474
+ def minify_js_content(text):
475
+ # Conservative minifier: strip block comments and drop empty/`//...` lines.
476
+ # (We intentionally do not try to remove inline `//` safely inside strings.)
477
+ text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
478
+ lines = []
479
+ for line in text.splitlines():
480
+ stripped = line.strip()
481
+ if not stripped or stripped.startswith("//"):
482
+ continue
483
+ lines.append(stripped)
484
+ return "".join(lines).strip()
485
+
486
+
487
+ def parse_config_scalar(raw):
488
+ if isinstance(raw, (int, float, bool)) or raw is None:
489
+ return raw
490
+ if not isinstance(raw, str):
491
+ return raw
492
+
493
+ stripped = raw.strip()
494
+ if not stripped:
495
+ return ""
496
+ if (stripped.startswith('"') and stripped.endswith('"')) or (stripped.startswith("'") and stripped.endswith("'")):
497
+ return stripped[1:-1]
498
+ return parse_literal_value(stripped)
499
+
500
+
501
+ def get_config_value(config, *path, default=None):
502
+ if not isinstance(config, dict) or not path:
503
+ return default
504
+
505
+ current = config
506
+ for part in path:
507
+ if isinstance(current, dict) and part in current:
508
+ current = current[part]
509
+ continue
510
+ dotted = ".".join(path)
511
+ if dotted in config:
512
+ return config[dotted]
513
+ return default
514
+ return current
515
+
516
+
517
+ def normalize_route_directory(rel_dir):
518
+ if not rel_dir or rel_dir == ".":
519
+ return ""
520
+ parts = []
521
+ for part in rel_dir.split(os.sep):
522
+ if not part or ROUTE_GROUP_DIR_RE.match(part):
523
+ continue
524
+ parts.append(part)
525
+ return os.path.join(*parts) if parts else ""
526
+
527
+
528
+ def resolve_static_asset_url(value):
529
+ if not isinstance(value, str):
530
+ return value
531
+ if value in ASSET_URL_MAP:
532
+ return ASSET_URL_MAP[value]
533
+ return value
534
+
535
+
536
+ def safe_relpath(path, start):
537
+ try:
538
+ return os.path.relpath(path, start)
539
+ except (ValueError, OSError):
540
+ return path
541
+
542
+
543
+ def load_build_manifest():
544
+ if not os.path.exists(BUILD_MANIFEST_FILE):
545
+ return {"version": BUILD_MANIFEST_VERSION, "pages": {}}
546
+ try:
547
+ with _file_lock(BUILD_MANIFEST_FILE, shared=True):
548
+ with open(BUILD_MANIFEST_FILE, "r", encoding="utf-8") as f:
549
+ data = json.load(f)
550
+ if not isinstance(data, dict):
551
+ return {"version": BUILD_MANIFEST_VERSION, "pages": {}}
552
+ data.setdefault("version", BUILD_MANIFEST_VERSION)
553
+ data.setdefault("pages", {})
554
+ return data
555
+ except (OSError, json.JSONDecodeError, ValueError, TypeError):
556
+ logger.warning("Failed to load build manifest; using empty manifest", exc_info=True)
557
+ return {"version": BUILD_MANIFEST_VERSION, "pages": {}}
558
+
559
+
560
+ def save_build_manifest(manifest):
561
+ os.makedirs(os.path.dirname(BUILD_MANIFEST_FILE), exist_ok=True)
562
+ manifest = dict(manifest or {})
563
+ manifest["version"] = BUILD_MANIFEST_VERSION
564
+ manifest.setdefault("pages", {})
565
+ with _file_lock(BUILD_MANIFEST_FILE, shared=False):
566
+ # Backup-before-write so a crash mid-write always has a fallback.
567
+ # (The primary protection is temp-file + atomic rename, but backups are
568
+ # helpful when the file is later corrupted by external factors.)
569
+ try:
570
+ if os.path.exists(BUILD_MANIFEST_FILE):
571
+ shutil.copy2(BUILD_MANIFEST_FILE, BUILD_MANIFEST_FILE + ".bak")
572
+ except (OSError, shutil.Error):
573
+ logger.warning("Failed to write build manifest backup", exc_info=True)
574
+ dir_path = os.path.dirname(BUILD_MANIFEST_FILE)
575
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=dir_path, delete=False) as tmp:
576
+ tmp_path = tmp.name
577
+ json.dump(manifest, tmp, indent=2, sort_keys=True)
578
+ tmp.flush()
579
+ os.fsync(tmp.fileno())
580
+ os.replace(tmp_path, BUILD_MANIFEST_FILE)
581
+
582
+
583
+ def save_dependency_graph(dependency_map):
584
+ os.makedirs(os.path.dirname(DEPENDENCY_GRAPH_FILE), exist_ok=True)
585
+ metadata = {}
586
+ if isinstance(dependency_map, dict) and "forward" in dependency_map:
587
+ metadata = dict(dependency_map.get("metadata") or {})
588
+ forward_map = dependency_map.get("forward") or {}
589
+ else:
590
+ forward_map = dependency_map or {}
591
+ reverse = {}
592
+ normalized_forward = {}
593
+ for page_key, dependencies in sorted(forward_map.items()):
594
+ normalized_deps = sorted(normalize_path(dep) for dep in dependencies)
595
+ normalized_forward[page_key] = normalized_deps
596
+ for dependency in dependencies:
597
+ dep_key = normalize_path(dependency)
598
+ reverse.setdefault(dep_key, []).append(page_key)
599
+ payload = {
600
+ "version": DEPENDENCY_GRAPH_VERSION,
601
+ "forward": normalized_forward,
602
+ "reverse": {dep: sorted(set(pages)) for dep, pages in sorted(reverse.items())},
603
+ "metadata": metadata,
604
+ }
605
+ with _file_lock(DEPENDENCY_GRAPH_FILE, shared=False):
606
+ # Backup-before-write (same rationale as build manifest).
607
+ try:
608
+ if os.path.exists(DEPENDENCY_GRAPH_FILE):
609
+ shutil.copy2(DEPENDENCY_GRAPH_FILE, DEPENDENCY_GRAPH_FILE + ".bak")
610
+ except (OSError, shutil.Error):
611
+ logger.warning("Failed to write dependency graph backup", exc_info=True)
612
+ dir_path = os.path.dirname(DEPENDENCY_GRAPH_FILE)
613
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=dir_path, delete=False) as tmp:
614
+ tmp_path = tmp.name
615
+ json.dump(payload, tmp, indent=2, sort_keys=True)
616
+ tmp.flush()
617
+ os.fsync(tmp.fileno())
618
+ os.replace(tmp_path, DEPENDENCY_GRAPH_FILE)
619
+
620
+
621
+ def file_fingerprint(path):
622
+ if not os.path.exists(path):
623
+ return None
624
+ stat = os.stat(path)
625
+ return {
626
+ "size": stat.st_size,
627
+ "mtime_ns": stat.st_mtime_ns,
628
+ }
629
+
630
+
631
+ def compute_dependency_signature(paths):
632
+ digest = hashlib.sha1()
633
+ for path in sorted(normalize_path(p) for p in paths):
634
+ digest.update(path.encode("utf-8"))
635
+ fp = file_fingerprint(path)
636
+ if fp is None:
637
+ digest.update(b"|missing")
638
+ else:
639
+ digest.update(f"|{fp['size']}|{fp['mtime_ns']}".encode("utf-8"))
640
+ return digest.hexdigest()
641
+
642
+
643
+ def collect_dependency_fingerprints(paths):
644
+ fingerprints = {}
645
+ for path in sorted(normalize_path(p) for p in paths):
646
+ fingerprints[path] = file_fingerprint(path)
647
+ return fingerprints
648
+
649
+
650
+ def describe_dependency_delta(previous_fingerprints, current_fingerprints):
651
+ previous_fingerprints = previous_fingerprints or {}
652
+ current_fingerprints = current_fingerprints or {}
653
+ for path in sorted(set(previous_fingerprints) | set(current_fingerprints)):
654
+ before = previous_fingerprints.get(path)
655
+ after = current_fingerprints.get(path)
656
+ if before != after:
657
+ rel = safe_relpath(path, PROJECT_ROOT)
658
+ if before is None:
659
+ return f"dependency added: {rel}"
660
+ if after is None:
661
+ return f"dependency removed: {rel}"
662
+ return f"dependency changed: {rel}"
663
+ return "dependency changed"
664
+
665
+
666
+ def page_cache_key(page_info):
667
+ return normalize_path(page_info["path"])
668
+
669
+
670
+ def cleanup_outputs(paths):
671
+ for path in paths or []:
672
+ if os.path.exists(path):
673
+ os.remove(path)
674
+
675
+
676
+ def remove_deleted_page_outputs(old_manifest, current_page_keys):
677
+ removed = 0
678
+ old_keys = set(old_manifest.get("pages", {}).keys())
679
+ for stale_key in sorted(old_keys - current_page_keys):
680
+ entry = old_manifest["pages"].pop(stale_key, None)
681
+ if entry:
682
+ cleanup_outputs(entry.get("outputs", []))
683
+ removed += 1
684
+ return removed
685
+
686
+
687
+ class PageNode:
688
+ def __init__(self):
689
+ self.title = ""
690
+ # Backwards compatible:
691
+ # - Old: `page.layout` was a single string (eg "main")
692
+ # - New: `page.layouts` is a chain (outer -> inner), eg ["base", "docs"]
693
+ self.layout = None
694
+ self.layouts = []
695
+ self.render_mode = "static"
696
+ self.revalidate = None
697
+ self.cache_by = None
698
+ self.cache_size = None
699
+ self.redirect_to = None
700
+ self.rewrite_to = None
701
+ self.head = HeadNode()
702
+ self.body = []
703
+ self.loaded_sheets = []
704
+ self.loaded_json = []
705
+ # `.twm` modules loaded via `load @./file.twm` (compiled into a page JS bundle)
706
+ self.loaded_modules = []
707
+ # Local `SCRIPT { ... }` blocks (treated as `.twm` modules)
708
+ self.local_modules = []
709
+ # Explicit client-side lifecycle hooks (never auto-run unless declared)
710
+ # Example:
711
+ # on load init init
712
+ self.on_load_inits = []
713
+ self.let_vars = {}
714
+ # Optional responsive helpers (enabled via `tw@responsive true|false`)
715
+ self.responsive = False
716
+ # Client-side reactive state variables (state { ... } block)
717
+ self.state_vars = {}
718
+ # Source file path for reactivity detection
719
+ self._tw_source_path = ""
720
+
721
+
722
+ class HeadNode:
723
+ def __init__(self):
724
+ self.metas = []
725
+ self.icon = None
726
+ self.seo = {}
727
+
728
+
729
+ class ElementNode:
730
+ def __init__(self, tag, text=None, token=None, file_path=None):
731
+ self.tag = tag
732
+ self.text = text
733
+ self.children = []
734
+ self.attrs = []
735
+ self.inline_style = []
736
+ self.events = []
737
+ self.router = {}
738
+ self.token = token
739
+ self.file_path = file_path
740
+
741
+
742
+ class ComponentNode:
743
+ def __init__(self, name, token=None, file_path=None):
744
+ self.tag = "__component__"
745
+ self.name = name
746
+ self.props = []
747
+ self.children = []
748
+ self.token = token
749
+ self.file_path = file_path
750
+
751
+
752
+ class ForNode:
753
+ def __init__(self, var_name, list_expr):
754
+ self.tag = "__for__"
755
+ self.var_name = var_name
756
+ self.list_expr = list_expr
757
+ self.children = []
758
+
759
+
760
+ class IfNode:
761
+ def __init__(self, condition):
762
+ self.tag = "__if__"
763
+ self.condition = condition
764
+ self.children = []
765
+ self.else_children = []
766
+
767
+
768
+ class LetNode:
769
+ def __init__(self, name, value):
770
+ self.tag = "__let__"
771
+ self.name = name
772
+ self.value = value
773
+
774
+
775
+ class ScriptNode:
776
+ def __init__(self, raw_js, token=None, file_path=None):
777
+ self.tag = "__script__"
778
+ self.raw_js = raw_js
779
+ self.children = []
780
+ self.token = token
781
+ self.file_path = file_path
782
+
783
+
784
+ class ScriptTagNode:
785
+ """
786
+ Declarative external script loader (Next.js <Script>-like).
787
+
788
+ Parsed from:
789
+ script { src "..." strategy afterInteractive|beforeInteractive|lazyOnload }
790
+
791
+ This is NOT raw JS. It is always explicit and safe by default.
792
+ """
793
+
794
+ def __init__(self, src: str, strategy: str = "afterInteractive", token=None, file_path=None):
795
+ self.tag = "__script_tag__"
796
+ self.src = src
797
+ self.strategy = strategy
798
+ self.children = []
799
+ self.token = token
800
+ self.file_path = file_path
801
+
802
+
803
+ class StyleSheetNode:
804
+ def __init__(self):
805
+ self.rules = []
806
+
807
+
808
+ class RuleNode:
809
+ def __init__(self, selector):
810
+ self.selector = selector
811
+ self.declarations = []
812
+ self.children = []
813
+
814
+
815
+ def is_identifier_boundary_char(ch):
816
+ return not (ch.isalnum() or ch == "_")
817
+
818
+
819
+ def tokenize(code, allow_inline_scripts=False):
820
+ global _SCRIPT_COUNTER
821
+ global _TWM_SCRIPT_COUNTER
822
+
823
+ tokens = []
824
+ i = 0
825
+ line = 1
826
+ col = 1
827
+ n = len(code)
828
+
829
+ def advance_one():
830
+ nonlocal i, line, col
831
+ ch = code[i]
832
+ i += 1
833
+ if ch == "\n":
834
+ line += 1
835
+ col = 1
836
+ else:
837
+ col += 1
838
+ return ch
839
+
840
+ def advance_count(count):
841
+ for _ in range(count):
842
+ advance_one()
843
+
844
+ # Basic operator/punctuation tokens that can appear inside expressions / literals
845
+ TWO_CHAR_OPS = {"==", "!=", ">=", "<=", "&&", "||"}
846
+ ONE_CHAR_OPS = set("[](),:=+-*/.%<>!")
847
+
848
+ def skip_block_comment():
849
+ # Assumes current position is at `/*`
850
+ nonlocal i
851
+ advance_count(2)
852
+ while i < n:
853
+ if code[i] == "*" and i + 1 < n and code[i + 1] == "/":
854
+ advance_count(2)
855
+ return
856
+ advance_one()
857
+ # Unterminated block comment: treat as EOF (do not crash the tokenizer)
858
+ return
859
+
860
+ def read_string(quote_char):
861
+ nonlocal i
862
+ start_line, start_col = line, col
863
+ advance_one() # consume opening quote
864
+ value = []
865
+ while i < n:
866
+ if code[i] == quote_char:
867
+ advance_one()
868
+ tokens.append(Token("STRING", "".join(value), start_line, start_col))
869
+ return True
870
+ if code[i] == "\\" and i + 1 < n:
871
+ advance_one()
872
+ value.append(advance_one())
873
+ else:
874
+ value.append(advance_one())
875
+ raise CompilerError("Unterminated string literal", Token("STRING", "", start_line, start_col))
876
+
877
+ def read_inline_script_block(open_token):
878
+ """
879
+ Parse `script { ... }` as a single placeholder token, but do brace matching
880
+ in a JS-aware way (ignore braces inside strings and comments) so nested
881
+ braces / quotes don't break the tokenizer.
882
+ """
883
+ nonlocal i
884
+ global _SCRIPT_COUNTER
885
+ # We are positioned right after the `script` word, and next non-ws char is `{`
886
+ # Consume whitespace then `{`
887
+ while i < n and code[i] in " \t\r\n":
888
+ if code[i] == "\n":
889
+ # Preserve newlines for error reporting consistency
890
+ tokens.append(Token("NL", "\n", line, col))
891
+ advance_one()
892
+ if i >= n or code[i] != "{":
893
+ return False
894
+ advance_one() # consume `{`
895
+
896
+ depth = 1
897
+ body = []
898
+ mode = "code" # code|string_d|string_s|template|line_comment|block_comment
899
+ quote = ""
900
+
901
+ while i < n:
902
+ ch = code[i]
903
+
904
+ if mode == "line_comment":
905
+ if ch == "\n":
906
+ mode = "code"
907
+ body.append(advance_one())
908
+ continue
909
+
910
+ if mode == "block_comment":
911
+ if ch == "*" and i + 1 < n and code[i + 1] == "/":
912
+ body.append(advance_one())
913
+ body.append(advance_one())
914
+ mode = "code"
915
+ continue
916
+ body.append(advance_one())
917
+ continue
918
+
919
+ if mode in {"string_d", "string_s"}:
920
+ body.append(advance_one())
921
+ if ch == "\\" and i < n:
922
+ body.append(advance_one())
923
+ continue
924
+ if (mode == "string_d" and ch == '"') or (mode == "string_s" and ch == "'"):
925
+ mode = "code"
926
+ continue
927
+
928
+ if mode == "template":
929
+ body.append(advance_one())
930
+ if ch == "\\" and i < n:
931
+ body.append(advance_one())
932
+ continue
933
+ if ch == "`":
934
+ mode = "code"
935
+ continue
936
+
937
+ # mode == "code"
938
+ if ch == "/" and i + 1 < n and code[i + 1] == "/":
939
+ body.append(advance_one())
940
+ body.append(advance_one())
941
+ mode = "line_comment"
942
+ continue
943
+ if ch == "/" and i + 1 < n and code[i + 1] == "*":
944
+ body.append(advance_one())
945
+ body.append(advance_one())
946
+ mode = "block_comment"
947
+ continue
948
+ if ch == '"':
949
+ mode = "string_d"
950
+ body.append(advance_one())
951
+ continue
952
+ if ch == "'":
953
+ mode = "string_s"
954
+ body.append(advance_one())
955
+ continue
956
+ if ch == "`":
957
+ mode = "template"
958
+ body.append(advance_one())
959
+ continue
960
+
961
+ if ch == "{":
962
+ depth += 1
963
+ body.append(advance_one())
964
+ continue
965
+ if ch == "}":
966
+ depth -= 1
967
+ if depth == 0:
968
+ advance_one() # consume final `}`
969
+ with _SCRIPT_LOCK:
970
+ uid = _SCRIPT_COUNTER
971
+ _SCRIPT_COUNTER += 1
972
+ INLINE_SCRIPTS[uid] = "".join(body)
973
+ tokens.append(Token("WORD", f"__TWSCRIPT{uid}__", open_token.line, open_token.col))
974
+ return True
975
+ body.append(advance_one())
976
+ continue
977
+
978
+ body.append(advance_one())
979
+
980
+ raise CompilerError("Unterminated `script { ... }` block", token=open_token)
981
+
982
+ def read_inline_twm_block(open_token):
983
+ """
984
+ Parse top-level `SCRIPT { ... }` as a single placeholder token, using the
985
+ same brace-matching strategy as inline JS scripts.
986
+
987
+ The content is NOT executed as-is; it is compiled as a `.twm` module and
988
+ registered into the TW module registry for explicit execution via events.
989
+ """
990
+ nonlocal i
991
+ global _TWM_SCRIPT_COUNTER
992
+ # Consume whitespace then `{`
993
+ while i < n and code[i] in " \t\r\n":
994
+ if code[i] == "\n":
995
+ tokens.append(Token("NL", "\n", line, col))
996
+ advance_one()
997
+ if i >= n or code[i] != "{":
998
+ return False
999
+ advance_one() # consume `{`
1000
+
1001
+ depth = 1
1002
+ body = []
1003
+ mode = "code" # code|string_d|string_s|template|line_comment|block_comment
1004
+
1005
+ while i < n:
1006
+ ch = code[i]
1007
+
1008
+ if mode == "line_comment":
1009
+ if ch == "\n":
1010
+ mode = "code"
1011
+ body.append(advance_one())
1012
+ continue
1013
+
1014
+ if mode == "block_comment":
1015
+ if ch == "*" and i + 1 < n and code[i + 1] == "/":
1016
+ body.append(advance_one())
1017
+ body.append(advance_one())
1018
+ mode = "code"
1019
+ continue
1020
+ body.append(advance_one())
1021
+ continue
1022
+
1023
+ if mode in {"string_d", "string_s"}:
1024
+ body.append(advance_one())
1025
+ if ch == "\\" and i < n:
1026
+ body.append(advance_one())
1027
+ continue
1028
+ if (mode == "string_d" and ch == '"') or (mode == "string_s" and ch == "'"):
1029
+ mode = "code"
1030
+ continue
1031
+
1032
+ if mode == "template":
1033
+ body.append(advance_one())
1034
+ if ch == "\\" and i < n:
1035
+ body.append(advance_one())
1036
+ continue
1037
+ if ch == "`":
1038
+ mode = "code"
1039
+ continue
1040
+
1041
+ # mode == "code"
1042
+ if ch == "/" and i + 1 < n and code[i + 1] == "/":
1043
+ body.append(advance_one())
1044
+ body.append(advance_one())
1045
+ mode = "line_comment"
1046
+ continue
1047
+ if ch == "/" and i + 1 < n and code[i + 1] == "*":
1048
+ body.append(advance_one())
1049
+ body.append(advance_one())
1050
+ mode = "block_comment"
1051
+ continue
1052
+ if ch == '"':
1053
+ mode = "string_d"
1054
+ body.append(advance_one())
1055
+ continue
1056
+ if ch == "'":
1057
+ mode = "string_s"
1058
+ body.append(advance_one())
1059
+ continue
1060
+ if ch == "`":
1061
+ mode = "template"
1062
+ body.append(advance_one())
1063
+ continue
1064
+
1065
+ if ch == "{":
1066
+ depth += 1
1067
+ body.append(advance_one())
1068
+ continue
1069
+ if ch == "}":
1070
+ depth -= 1
1071
+ if depth == 0:
1072
+ advance_one() # consume final `}`
1073
+ with _SCRIPT_LOCK:
1074
+ uid = _TWM_SCRIPT_COUNTER
1075
+ _TWM_SCRIPT_COUNTER += 1
1076
+ INLINE_TWM_SCRIPTS[uid] = "".join(body)
1077
+ tokens.append(Token("WORD", f"__TWTWM{uid}__", open_token.line, open_token.col))
1078
+ return True
1079
+ body.append(advance_one())
1080
+ continue
1081
+
1082
+ body.append(advance_one())
1083
+
1084
+ raise CompilerError("Unterminated `SCRIPT { ... }` block", token=open_token)
1085
+
1086
+ while i < n:
1087
+ ch = code[i]
1088
+
1089
+ # Preserve newline as a token so value parsing can stop at EOL reliably.
1090
+ if ch == "\n":
1091
+ tokens.append(Token("NL", "\n", line, col))
1092
+ advance_one()
1093
+ continue
1094
+ if ch in " \t\r":
1095
+ advance_one()
1096
+ continue
1097
+
1098
+ if ch == "/" and i + 1 < n and code[i + 1] == "/":
1099
+ while i < n and code[i] != "\n":
1100
+ advance_one()
1101
+ continue
1102
+ if ch == "/" and i + 1 < n and code[i + 1] == "*":
1103
+ skip_block_comment()
1104
+ continue
1105
+
1106
+ if ch in "{}":
1107
+ tokens.append(Token("BRACE", ch, line, col))
1108
+ advance_one()
1109
+ continue
1110
+
1111
+ if ch in {'"', "'"}:
1112
+ read_string(ch)
1113
+ continue
1114
+
1115
+ # Unquoted path-style token: `@./relative/path.ext` (used by `load @path`).
1116
+ # Read greedily so dots/slashes inside the path don't get split into
1117
+ # separate ONE_CHAR_OPS tokens.
1118
+ if ch == "@":
1119
+ at_start_line, at_start_col = line, col
1120
+ at_chars = []
1121
+ while i < n and code[i] not in ' \t\r\n{}"\'':
1122
+ at_chars.append(advance_one())
1123
+ tokens.append(Token("WORD", "".join(at_chars), at_start_line, at_start_col))
1124
+ continue
1125
+
1126
+ # Operators / punctuation as standalone tokens (helps expression parsing)
1127
+ if i + 1 < n and (code[i:i + 2] in TWO_CHAR_OPS):
1128
+ tokens.append(Token("WORD", code[i:i + 2], line, col))
1129
+ advance_count(2)
1130
+ continue
1131
+ if ch in ONE_CHAR_OPS:
1132
+ tokens.append(Token("WORD", ch, line, col))
1133
+ advance_one()
1134
+ continue
1135
+
1136
+ start_line, start_col = line, col
1137
+ word = []
1138
+ # Read until whitespace, braces, quotes, or common operators.
1139
+ while i < n and (code[i] not in ' \t\r\n{}"\'' and code[i] not in ONE_CHAR_OPS):
1140
+ word.append(advance_one())
1141
+ word = "".join(word)
1142
+ if not word:
1143
+ continue
1144
+
1145
+ if allow_inline_scripts and word == "script":
1146
+ prev_ok = len(tokens) == 0 or is_identifier_boundary_char(code[max(i - len(word) - 1, 0)]) if i - len(word) - 1 >= 0 else True
1147
+ if prev_ok:
1148
+ if read_inline_script_block(Token("WORD", "script", start_line, start_col)):
1149
+ continue
1150
+
1151
+ if allow_inline_scripts and word == "SCRIPT":
1152
+ prev_ok = len(tokens) == 0 or is_identifier_boundary_char(code[max(i - len(word) - 1, 0)]) if i - len(word) - 1 >= 0 else True
1153
+ if prev_ok:
1154
+ if read_inline_twm_block(Token("WORD", "SCRIPT", start_line, start_col)):
1155
+ continue
1156
+
1157
+ tokens.append(Token("WORD", word, start_line, start_col))
1158
+
1159
+ return tokens
1160
+
1161
+
1162
+ def tokenize_tw(code):
1163
+ return tokenize(code, allow_inline_scripts=True)
1164
+
1165
+
1166
+ def classify_known_prop(name):
1167
+ nl = name.lower()
1168
+ if nl in ROUTER_KEYS:
1169
+ return "router"
1170
+ if nl in EVENTS or (nl.startswith("on") and nl[2:] in EVENTS):
1171
+ return "event"
1172
+ if nl in HTML_ATTRIBUTES or nl.startswith("data-") or nl.startswith("aria-"):
1173
+ return "attr"
1174
+ if nl in CSS_PROPERTIES or nl in CSS_ALIASES:
1175
+ return "css"
1176
+ return "unknown"
1177
+
1178
+
1179
+ def normalize_css_prop(name):
1180
+ return CSS_ALIASES.get(name.lower(), name.lower())
1181
+
1182
+
1183
+ def normalize_attr_name(name):
1184
+ return name if any(c.isupper() for c in name) else name.lower()
1185
+
1186
+
1187
+ def component_exists(name):
1188
+ with _CACHE_LOCK:
1189
+ if name in _COMPONENT_EXISTS_CACHE:
1190
+ return _COMPONENT_EXISTS_CACHE[name]
1191
+ path = resolve_component_path(name)
1192
+ found = bool(path and os.path.exists(path))
1193
+ with _CACHE_LOCK:
1194
+ _COMPONENT_EXISTS_CACHE[name] = found
1195
+ return found
1196
+
1197
+
1198
+ def is_component_name(name):
1199
+ # Only treat it as a component if it actually exists.
1200
+ # This avoids errors like `Section {}` being treated as a missing component.
1201
+ return component_exists(name)
1202
+
1203
+
1204
+ def resolve_component_path(name):
1205
+ # 1) Support nested component folders via `import "ui/Button"` (path-like names)
1206
+ name = str(name or "")
1207
+ if not name:
1208
+ raise CompilerError("Component name cannot be empty")
1209
+ if "\x00" in name:
1210
+ raise CompilerError(
1211
+ f"Invalid component name: `{name}`",
1212
+ suggestion="Remove null bytes from the component name.",
1213
+ )
1214
+ if ".." in name:
1215
+ raise CompilerError(
1216
+ f"Invalid component name: `{name}`",
1217
+ suggestion="Component names cannot contain `..` segments.",
1218
+ )
1219
+ if os.path.isabs(name) or re.match(r"^[A-Za-z]:[\\/]", name):
1220
+ raise CompilerError(
1221
+ f"Invalid component name: `{name}`",
1222
+ suggestion="Use a component name relative to `[home]/components/`, not an absolute path.",
1223
+ )
1224
+ treat_backslash_as_sep = (os.sep == "\\")
1225
+ if "/" in name or (treat_backslash_as_sep and "\\" in name):
1226
+ rel = name.replace("\\", "/").lstrip("/")
1227
+ # Block path traversal / absolute paths
1228
+ rel_norm = os.path.normpath(rel).replace("\\", "/")
1229
+ if rel_norm.startswith("../") or rel_norm == ".." or rel_norm.startswith("/"):
1230
+ raise CompilerError(
1231
+ f"Invalid component import path: `{name}`",
1232
+ suggestion="Use a path relative to `[home]/components/` without `..` segments.",
1233
+ )
1234
+ return os.path.join(COMPONENTS_DIR, rel_norm + ".tw")
1235
+
1236
+ # 2) Fast path: direct component file
1237
+ direct = os.path.join(COMPONENTS_DIR, f"{name}.tw")
1238
+ if os.path.exists(direct):
1239
+ return direct
1240
+
1241
+ # 3) Fallback: search in subfolders (allows organizing components in nested dirs)
1242
+ with _CACHE_LOCK:
1243
+ if name in _COMPONENT_PATH_CACHE:
1244
+ return _COMPONENT_PATH_CACHE[name]
1245
+ found = ""
1246
+ if os.path.isdir(COMPONENTS_DIR):
1247
+ target = f"{name}.tw"
1248
+ for root, _, files in os.walk(COMPONENTS_DIR):
1249
+ if target in files:
1250
+ found = os.path.join(root, target)
1251
+ break
1252
+ with _CACHE_LOCK:
1253
+ _COMPONENT_PATH_CACHE[name] = found or direct
1254
+ return _COMPONENT_PATH_CACHE[name]
1255
+
1256
+
1257
+ def component_name_from_path(path):
1258
+ full_path = normalize_path(path)
1259
+ try:
1260
+ rel_path = os.path.relpath(full_path, COMPONENTS_DIR).replace("\\", "/")
1261
+ except (ValueError, OSError):
1262
+ return ""
1263
+ if rel_path.startswith("../") or rel_path == "..":
1264
+ return ""
1265
+ if not rel_path.lower().endswith(".tw"):
1266
+ return ""
1267
+ return rel_path[:-3]
1268
+
1269
+
1270
+ def resolve_load_target(raw_path, base_dir, *, token=None, location="load"):
1271
+ rel_path = str(raw_path or "")
1272
+ if not rel_path:
1273
+ raise CompilerError(f"{location}: path cannot be empty", token=token)
1274
+ if "\x00" in rel_path:
1275
+ raise CompilerError(
1276
+ f"{location}: invalid path `{rel_path}`",
1277
+ token=token,
1278
+ suggestion="Remove null bytes from the path.",
1279
+ )
1280
+
1281
+ requested_path = resolve_source_path(rel_path, base_dir)
1282
+ base_candidate = requested_path
1283
+ root, ext = os.path.splitext(base_candidate)
1284
+
1285
+ candidates = []
1286
+ if ext:
1287
+ candidates.append(base_candidate)
1288
+ else:
1289
+ candidates.extend(
1290
+ [
1291
+ base_candidate + ".tw",
1292
+ base_candidate + ".twm",
1293
+ base_candidate + ".tss",
1294
+ base_candidate + ".json",
1295
+ base_candidate,
1296
+ ]
1297
+ )
1298
+
1299
+ existing = []
1300
+ seen = set()
1301
+ for candidate in candidates:
1302
+ normalized = normalize_path(candidate)
1303
+ if normalized in seen:
1304
+ continue
1305
+ seen.add(normalized)
1306
+ if os.path.exists(normalized):
1307
+ existing.append(normalized)
1308
+
1309
+ display_path = rel_path if rel_path.startswith("@") else f"@{rel_path}"
1310
+ if not existing:
1311
+ expected = ", ".join(f"`{candidate}`" for candidate in candidates[:3])
1312
+ raise CompilerError(
1313
+ f"{location}: file not found for `{display_path}`",
1314
+ token=token,
1315
+ suggestion=f"Expected one of: {expected}",
1316
+ )
1317
+
1318
+ if len(existing) > 1:
1319
+ choices = ", ".join(f"`{candidate}`" for candidate in existing)
1320
+ raise CompilerError(
1321
+ f"{location}: ambiguous path `{display_path}`",
1322
+ token=token,
1323
+ suggestion=f"Add an explicit extension. Matches: {choices}",
1324
+ )
1325
+
1326
+ full_path = existing[0]
1327
+ if os.path.isdir(full_path):
1328
+ raise CompilerError(
1329
+ f"{location}: expected a file but got a directory: `{full_path}`",
1330
+ token=token,
1331
+ suggestion="Point `load` to a file such as `.tw`, `.twm`, `.tss`, or `.json`.",
1332
+ )
1333
+
1334
+ resolved_ext = os.path.splitext(full_path)[1].lower()
1335
+ kind_map = {
1336
+ ".tw": "component",
1337
+ ".twm": "module",
1338
+ ".tss": "stylesheet",
1339
+ ".json": "json",
1340
+ }
1341
+ if resolved_ext not in kind_map:
1342
+ raise CompilerError(
1343
+ f"{location}: unsupported file type `{resolved_ext or '<none>'}` for `{full_path}`",
1344
+ token=token,
1345
+ suggestion="`load` currently supports `.tw`, `.twm`, `.tss`, and `.json` files.",
1346
+ )
1347
+
1348
+ return {
1349
+ "kind": kind_map[resolved_ext],
1350
+ "full_path": full_path,
1351
+ "display_path": display_path,
1352
+ }
1353
+
1354
+
1355
+ def extract_directives_from_source(raw, base_dir):
1356
+ imports = IMPORT_RE.findall(raw)
1357
+ layouts = []
1358
+ for quoted, bare in LAYOUT_RE.findall(raw):
1359
+ name = quoted or bare
1360
+ if name:
1361
+ layouts.append(name)
1362
+ stylesheets = []
1363
+ json_files = []
1364
+ component_files = []
1365
+ module_files = []
1366
+ for quoted, atpath in LOAD_RE.findall(raw):
1367
+ load_info = resolve_load_target(quoted or atpath, base_dir)
1368
+ if load_info["kind"] == "json":
1369
+ json_files.append(load_info["full_path"])
1370
+ elif load_info["kind"] == "stylesheet":
1371
+ stylesheets.append(load_info["full_path"])
1372
+ elif load_info["kind"] == "module":
1373
+ module_files.append(load_info["full_path"])
1374
+ else:
1375
+ component_files.append(load_info["full_path"])
1376
+ return {
1377
+ "imports": imports,
1378
+ "layouts": layouts,
1379
+ "stylesheets": stylesheets,
1380
+ "json_files": json_files,
1381
+ "component_files": component_files,
1382
+ "module_files": module_files,
1383
+ }
1384
+
1385
+
1386
+ def collect_component_dependencies(name, stack=None, seen=None):
1387
+ with _CACHE_LOCK:
1388
+ if name in _COMPONENT_DEP_GRAPH_CACHE:
1389
+ return set(_COMPONENT_DEP_GRAPH_CACHE[name])
1390
+
1391
+ stack = list(stack or [])
1392
+ seen = seen or set()
1393
+
1394
+ if name in stack:
1395
+ chain = " -> ".join(stack + [name])
1396
+ raise CompilerError(
1397
+ f"Circular component import detected: {chain}",
1398
+ file_path=resolve_component_path(name),
1399
+ suggestion="Keep the import graph acyclic, or move shared code into a separate component.",
1400
+ )
1401
+
1402
+ path = resolve_component_path(name)
1403
+ if not os.path.exists(path):
1404
+ raise CompilerError(
1405
+ f"Component not found: `{name}`",
1406
+ file_path=path,
1407
+ suggestion=f"Expected file: `{path}`",
1408
+ )
1409
+
1410
+ if name in seen:
1411
+ return set()
1412
+
1413
+ seen.add(name)
1414
+ raw = read_text_file(path)
1415
+ deps = {normalize_path(path)}
1416
+ directives = extract_directives_from_source(raw, os.path.dirname(path))
1417
+ deps.update(normalize_path(sheet) for sheet in directives.get("stylesheets", []))
1418
+ deps.update(normalize_path(payload) for payload in directives.get("json_files", []))
1419
+
1420
+ for child_name in directives["imports"]:
1421
+ deps.update(collect_component_dependencies(child_name, stack + [name], seen))
1422
+
1423
+ with _CACHE_LOCK:
1424
+ _COMPONENT_DEP_GRAPH_CACHE[name] = sorted(deps)
1425
+ return set(deps)
1426
+
1427
+
1428
+ def collect_page_dependencies(tw_path):
1429
+ tw_path = normalize_path(tw_path)
1430
+ base_dir = os.path.dirname(tw_path)
1431
+ raw = read_text_file(tw_path)
1432
+ directives = extract_directives_from_source(raw, base_dir)
1433
+
1434
+ deps = {tw_path, normalize_path(CONFIG_FILE)}
1435
+ sibling_json = normalize_path(tw_path[:-3] + ".json")
1436
+ file_name = os.path.basename(tw_path)
1437
+ if classify_dynamic_route_file(file_name) and os.path.exists(sibling_json):
1438
+ deps.add(sibling_json)
1439
+ if os.path.exists(STYLE_FILE):
1440
+ deps.add(normalize_path(STYLE_FILE))
1441
+
1442
+ for stylesheet_path in directives["stylesheets"]:
1443
+ deps.add(stylesheet_path)
1444
+ for json_path in directives.get("json_files", []):
1445
+ deps.add(json_path)
1446
+ for module_path in directives.get("module_files", []):
1447
+ deps.add(module_path)
1448
+ for component_path in directives.get("component_files", []):
1449
+ deps.add(component_path)
1450
+ component_name = component_name_from_path(component_path)
1451
+ if component_name:
1452
+ deps.update(collect_component_dependencies(component_name))
1453
+
1454
+ for layout_name in directives["layouts"]:
1455
+ layout_path = normalize_path(os.path.join(LAYOUTS_DIR, f"{layout_name}.tw"))
1456
+ deps.add(layout_path)
1457
+ if os.path.exists(layout_path):
1458
+ layout_raw = read_text_file(layout_path)
1459
+ for quoted, atpath in LAYOUT_LOAD_RE.findall(layout_raw):
1460
+ load_info = resolve_load_target(quoted or atpath, HOME_DIR, location="layout load")
1461
+ loaded_path = load_info["full_path"]
1462
+ deps.add(loaded_path)
1463
+ if load_info["kind"] == "component" and os.path.exists(loaded_path):
1464
+ # one level deep: if that component itself loads a stylesheet, track it too
1465
+ inner_raw = read_text_file(loaded_path)
1466
+ for q2, a2 in COMPONENT_LOAD_RE.findall(inner_raw):
1467
+ inner_load_info = resolve_load_target(
1468
+ q2 or a2,
1469
+ os.path.dirname(loaded_path),
1470
+ location="component load",
1471
+ )
1472
+ deps.add(inner_load_info["full_path"])
1473
+
1474
+ for component_name in directives["imports"]:
1475
+ deps.update(collect_component_dependencies(component_name))
1476
+
1477
+ return sorted(deps)
1478
+
1479
+
1480
+ def route_path_from_page_info(page_info, item=None):
1481
+ route_parts = []
1482
+ rel_dir = page_info.get("rel_dir", "")
1483
+ if rel_dir:
1484
+ route_parts.append(rel_dir)
1485
+ if page_info.get("type") == "dynamic":
1486
+ route_parts.extend(resolve_dynamic_segments(page_info, item or {}))
1487
+ else:
1488
+ name = page_info.get("name", "index")
1489
+ if name != "index":
1490
+ route_parts.append(name)
1491
+ route = "/" + "/".join(filter(None, route_parts))
1492
+ return route or "/"
1493
+
1494
+
1495
+ def collect_page_metadata(page_info, page_ast=None, route_path=None, *, pipeline="legacy", item=None):
1496
+ page_ast = page_ast or load_page_ast_from_file(page_info["path"])
1497
+ raw = read_text_file(page_info["path"])
1498
+ directives = extract_directives_from_source(raw, os.path.dirname(page_info["path"]))
1499
+ layouts = list(getattr(page_ast, "layouts", None) or [])
1500
+ if not layouts and getattr(page_ast, "layout", None):
1501
+ layouts = [page_ast.layout]
1502
+ return {
1503
+ "pipeline": pipeline,
1504
+ "route_path": route_path or route_path_from_page_info(page_info, item=item),
1505
+ "page_type": page_info.get("type", "static"),
1506
+ "route_kind": page_info.get("route_kind", "static"),
1507
+ "param": page_info.get("param"),
1508
+ "render_mode": getattr(page_ast, "render_mode", "static"),
1509
+ "revalidate": getattr(page_ast, "revalidate", None),
1510
+ "cache_by": getattr(page_ast, "cache_by", None),
1511
+ "cache_size": getattr(page_ast, "cache_size", None),
1512
+ "layouts": layouts,
1513
+ "components": sorted(set(directives.get("imports", []))),
1514
+ "source": normalize_path(page_info["path"]),
1515
+ }
1516
+
1517
+
1518
+ def create_request_context(route_path, params=None):
1519
+ return {"path": route_path or "/", "params": dict(params or {}), "env": dict(os.environ)}
1520
+
1521
+
1522
+ def build_page_context(page_info, page_ast=None, tw_path=None, *, item=None, route_path=None, request_params=None):
1523
+ tw_path = tw_path or page_info["path"]
1524
+ page_ast = page_ast or load_page_ast_from_file(tw_path)
1525
+ params = dict(request_params or {})
1526
+ context = create_base_context(page_ast, tw_path)
1527
+ if isinstance(item, dict):
1528
+ context.update(item)
1529
+ if page_info.get("type") == "dynamic":
1530
+ param_name = page_info.get("param")
1531
+ segments = resolve_dynamic_segments(page_info, item or {})
1532
+ if param_name:
1533
+ params.setdefault(param_name, "/".join(segments))
1534
+ if page_info.get("route_kind") != "single":
1535
+ params.setdefault(param_name + "Segments", segments)
1536
+ context.update(params)
1537
+ resolved_route = route_path or route_path_from_page_info(page_info, item=item)
1538
+ context["_tw_route"] = resolved_route or "/"
1539
+ context["request"] = create_request_context(resolved_route or "/", params)
1540
+ return context
1541
+
1542
+
1543
+ def parse_literal_value(raw):
1544
+ if isinstance(raw, (int, float, bool)) or raw is None:
1545
+ return raw
1546
+ if not isinstance(raw, str):
1547
+ return raw
1548
+
1549
+ stripped = raw.strip()
1550
+ lower = stripped.lower()
1551
+ if lower == "true":
1552
+ return True
1553
+ if lower == "false":
1554
+ return False
1555
+ if lower in {"null", "none"}:
1556
+ return None
1557
+ if NUM_RE.match(stripped):
1558
+ return float(stripped) if "." in stripped else int(stripped)
1559
+ if (stripped.startswith("{") and stripped.endswith("}")) or (stripped.startswith("[") and stripped.endswith("]")):
1560
+ try:
1561
+ return json.loads(stripped)
1562
+ except json.JSONDecodeError:
1563
+ # Allow trailing commas and a slightly more forgiving literal syntax
1564
+ # via Python's safe literal parser.
1565
+ try:
1566
+ return ast.literal_eval(stripped)
1567
+ except (ValueError, SyntaxError):
1568
+ # Optional debug: surface parsing failures instead of silently swallowing.
1569
+ if os.environ.get("TW_WARN_LITERAL_PARSE", "").strip().lower() in {"1", "true", "yes", "on"}:
1570
+ log(f"⚠️ Literal parse failed, treating as string: {raw!r}", level="warning")
1571
+ return raw
1572
+ return raw
1573
+
1574
+
1575
+ def to_bool(value):
1576
+ if isinstance(value, bool):
1577
+ return value
1578
+ if isinstance(value, (int, float)):
1579
+ return value != 0
1580
+ if value is None:
1581
+ return False
1582
+ if isinstance(value, str):
1583
+ return value.strip().lower() not in {"", "0", "false", "none", "null"}
1584
+ if isinstance(value, (list, tuple, dict, set)):
1585
+ return len(value) > 0
1586
+ return bool(value)
1587
+
1588
+
1589
+ def resolve_path(path, context):
1590
+ current = context
1591
+ for part in path.split("."):
1592
+ if isinstance(current, dict) and part in current:
1593
+ current = current[part]
1594
+ else:
1595
+ return None
1596
+ return current
1597
+
1598
+
1599
+ def _transform_logic_operators(expr):
1600
+ expr = expr.replace("&&", " and ")
1601
+ expr = expr.replace("||", " or ")
1602
+ expr = re.sub(r"(?<![=!<>])!(?!=)", " not ", expr)
1603
+ return expr
1604
+
1605
+
1606
+ def _safe_eval(node, context):
1607
+ if isinstance(node, ast.Expression):
1608
+ return _safe_eval(node.body, context)
1609
+
1610
+ if isinstance(node, ast.Constant):
1611
+ return node.value
1612
+
1613
+ if isinstance(node, ast.Name):
1614
+ if node.id in {"true", "false", "null", "none"}:
1615
+ return {"true": True, "false": False, "null": None, "none": None}[node.id]
1616
+ return context.get(node.id)
1617
+
1618
+ if isinstance(node, ast.Attribute):
1619
+ base = _safe_eval(node.value, context)
1620
+ if isinstance(base, dict):
1621
+ return base.get(node.attr)
1622
+ return getattr(base, node.attr, None)
1623
+
1624
+ if isinstance(node, ast.Subscript):
1625
+ base = _safe_eval(node.value, context)
1626
+ index = _safe_eval(node.slice, context)
1627
+ try:
1628
+ return base[index]
1629
+ except (TypeError, KeyError, IndexError):
1630
+ return None
1631
+
1632
+ if isinstance(node, ast.Index):
1633
+ return _safe_eval(node.value, context)
1634
+
1635
+ if isinstance(node, ast.List):
1636
+ return [_safe_eval(item, context) for item in node.elts]
1637
+
1638
+ if isinstance(node, ast.Tuple):
1639
+ return tuple(_safe_eval(item, context) for item in node.elts)
1640
+
1641
+ if isinstance(node, ast.Dict):
1642
+ return {
1643
+ _safe_eval(k, context): _safe_eval(v, context)
1644
+ for k, v in zip(node.keys, node.values)
1645
+ }
1646
+
1647
+ if isinstance(node, ast.BoolOp):
1648
+ if isinstance(node.op, ast.And):
1649
+ for value_node in node.values:
1650
+ result = _safe_eval(value_node, context)
1651
+ if not to_bool(result):
1652
+ return result
1653
+ return result
1654
+ if isinstance(node.op, ast.Or):
1655
+ for value_node in node.values:
1656
+ result = _safe_eval(value_node, context)
1657
+ if to_bool(result):
1658
+ return result
1659
+ return result
1660
+
1661
+ if isinstance(node, ast.UnaryOp):
1662
+ value = _safe_eval(node.operand, context)
1663
+ if isinstance(node.op, ast.Not):
1664
+ return not to_bool(value)
1665
+ if isinstance(node.op, ast.USub):
1666
+ return -value
1667
+ if isinstance(node.op, ast.UAdd):
1668
+ return +value
1669
+
1670
+ if isinstance(node, ast.BinOp):
1671
+ left = _safe_eval(node.left, context)
1672
+ right = _safe_eval(node.right, context)
1673
+ if isinstance(node.op, ast.Add):
1674
+ return left + right
1675
+ if isinstance(node.op, ast.Sub):
1676
+ return left - right
1677
+ if isinstance(node.op, ast.Mult):
1678
+ return left * right
1679
+ if isinstance(node.op, ast.Div):
1680
+ return left / right
1681
+ if isinstance(node.op, ast.Mod):
1682
+ return left % right
1683
+
1684
+ if isinstance(node, ast.Compare):
1685
+ left = _safe_eval(node.left, context)
1686
+ for op, comparator_node in zip(node.ops, node.comparators):
1687
+ right = _safe_eval(comparator_node, context)
1688
+ ok = False
1689
+ if isinstance(op, ast.Eq):
1690
+ ok = left == right
1691
+ elif isinstance(op, ast.NotEq):
1692
+ ok = left != right
1693
+ elif isinstance(op, ast.Gt):
1694
+ ok = left > right
1695
+ elif isinstance(op, ast.GtE):
1696
+ ok = left >= right
1697
+ elif isinstance(op, ast.Lt):
1698
+ ok = left < right
1699
+ elif isinstance(op, ast.LtE):
1700
+ ok = left <= right
1701
+ elif isinstance(op, ast.In):
1702
+ ok = left in right
1703
+ elif isinstance(op, ast.NotIn):
1704
+ ok = left not in right
1705
+ else:
1706
+ raise ValueError("Unsupported comparison operator")
1707
+ if not ok:
1708
+ return False
1709
+ left = right
1710
+ return True
1711
+
1712
+ raise ValueError(f"Unsupported expression node: {type(node).__name__}")
1713
+
1714
+
1715
+ def evaluate_expression(expr, context):
1716
+ expr = expr.strip()
1717
+ if not expr:
1718
+ return ""
1719
+ try:
1720
+ transformed = _transform_logic_operators(expr)
1721
+ tree = ast.parse(transformed, mode="eval")
1722
+ return _safe_eval(tree, context)
1723
+ except (SyntaxError, ValueError):
1724
+ value = resolve_path(expr, context)
1725
+ if value is not None:
1726
+ return value
1727
+ return None
1728
+ except Exception as err:
1729
+ # Do not silently swallow runtime errors (e.g. ZeroDivisionError) without any clue.
1730
+ if os.environ.get("TW_STRICT_EVAL", "").strip().lower() in {"1", "true", "yes", "on"}:
1731
+ raise
1732
+ log(
1733
+ f"⚠️ Expression eval failed: {expr!r} ({type(err).__name__}: {err})",
1734
+ level="warning",
1735
+ )
1736
+ value = resolve_path(expr, context)
1737
+ if value is not None:
1738
+ return value
1739
+ return None
1740
+
1741
+
1742
+ PLACEHOLDER_MOUSTACHE_RE = re.compile(r"\{\{([^{}]+)\}\}")
1743
+ RESERVED_EXPR_NAMES = {"true", "false", "null", "none"}
1744
+
1745
+
1746
+ def extract_placeholder_expressions(text):
1747
+ if text is None or "{" not in str(text):
1748
+ return []
1749
+ expressions = []
1750
+ for match in PLACEHOLDER_MOUSTACHE_RE.finditer(str(text)):
1751
+ expr = match.group(1).strip()
1752
+ if expr:
1753
+ expressions.append(expr)
1754
+ for match in INTERPOLATION_RE.finditer(str(text)):
1755
+ expr = match.group(1).strip()
1756
+ if expr:
1757
+ expressions.append(expr)
1758
+ return expressions
1759
+
1760
+
1761
+ class ExpressionNameCollector(ast.NodeVisitor):
1762
+ def __init__(self):
1763
+ self.names = []
1764
+
1765
+ def visit_Name(self, node):
1766
+ self.names.append(node.id)
1767
+
1768
+
1769
+ def collect_expression_names(expr):
1770
+ expr = str(expr or "").strip()
1771
+ if not expr:
1772
+ return []
1773
+ try:
1774
+ transformed = _transform_logic_operators(expr)
1775
+ tree = ast.parse(transformed, mode="eval")
1776
+ except (SyntaxError, ValueError):
1777
+ return []
1778
+ collector = ExpressionNameCollector()
1779
+ collector.visit(tree)
1780
+ names = []
1781
+ seen = set()
1782
+ for name in collector.names:
1783
+ if name in RESERVED_EXPR_NAMES or name in seen:
1784
+ continue
1785
+ seen.add(name)
1786
+ names.append(name)
1787
+ return names
1788
+
1789
+
1790
+ def build_diagnostic(severity, code, message, file_path, token=None, suggestion=None, notes=None):
1791
+ line = getattr(token, "line", 0) or 0
1792
+ col = getattr(token, "col", 0) or 0
1793
+ return Diagnostic(
1794
+ severity=severity,
1795
+ code=code,
1796
+ message=message,
1797
+ file_path=file_path or "",
1798
+ line=line,
1799
+ col=col,
1800
+ end_line=line,
1801
+ end_col=col,
1802
+ suggestion=suggestion,
1803
+ notes=list(notes or []),
1804
+ )
1805
+
1806
+
1807
+ def collect_known_scope_names(context):
1808
+ names = set(context.keys() if isinstance(context, dict) else [])
1809
+ names.update({"config", "site", "env", "request", "props", "children"})
1810
+ return names
1811
+
1812
+
1813
+ def analyze_expression_symbols(expr, scope_names, diagnostics, token=None, file_path=None, label="expression"):
1814
+ for name in collect_expression_names(expr):
1815
+ if name.startswith("_tw_") or name in scope_names:
1816
+ continue
1817
+ diagnostics.append(build_diagnostic(
1818
+ "warning",
1819
+ "TW2001",
1820
+ f"Undefined symbol `{name}` in {label}.",
1821
+ file_path=file_path or getattr(token, "file_path", "") or "",
1822
+ token=token,
1823
+ suggestion="Define the symbol, use JSON load/let/import, or remove braces if you want literal placeholder text.",
1824
+ notes=[f"Expression: {expr}"],
1825
+ ))
1826
+
1827
+
1828
+ def analyze_interpolated_text(text, scope_names, diagnostics, token=None, file_path=None, label="template"):
1829
+ for expr in extract_placeholder_expressions(text):
1830
+ analyze_expression_symbols(expr, scope_names, diagnostics, token=token, file_path=file_path, label=label)
1831
+
1832
+
1833
+ def _append_unique_diagnostic(diagnostics, diagnostic, seen_keys):
1834
+ key = (
1835
+ diagnostic.severity,
1836
+ diagnostic.code,
1837
+ diagnostic.file_path,
1838
+ diagnostic.line,
1839
+ diagnostic.col,
1840
+ diagnostic.message,
1841
+ )
1842
+ if key in seen_keys:
1843
+ return
1844
+ seen_keys.add(key)
1845
+ diagnostics.append(diagnostic)
1846
+
1847
+
1848
+ def analyze_nodes_semantics(nodes, scope_names, diagnostics, file_path, component_stack=None, seen_keys=None):
1849
+ current_scope = set(scope_names)
1850
+ component_stack = list(component_stack or [])
1851
+ seen_keys = seen_keys if seen_keys is not None else set()
1852
+
1853
+ for node in nodes or []:
1854
+ token = getattr(node, "token", None)
1855
+ node_file_path = getattr(node, "file_path", None) or file_path
1856
+
1857
+ if isinstance(node, LetNode):
1858
+ analyze_interpolated_text(node.value, current_scope, diagnostics, token=token, file_path=node_file_path, label=f"`let {node.name}` value")
1859
+ current_scope.add(node.name)
1860
+ continue
1861
+
1862
+ if isinstance(node, ForNode):
1863
+ analyze_expression_symbols(node.list_expr, current_scope, diagnostics, token=token, file_path=node_file_path, label=f"`for {node.var_name} in ...`")
1864
+ child_scope = set(current_scope)
1865
+ child_scope.add(node.var_name)
1866
+ analyze_nodes_semantics(node.children, child_scope, diagnostics, node_file_path, component_stack=component_stack, seen_keys=seen_keys)
1867
+ continue
1868
+
1869
+ if isinstance(node, IfNode):
1870
+ analyze_expression_symbols(node.condition, current_scope, diagnostics, token=token, file_path=node_file_path, label="`if` condition")
1871
+ analyze_nodes_semantics(node.children, current_scope, diagnostics, node_file_path, component_stack=component_stack, seen_keys=seen_keys)
1872
+ analyze_nodes_semantics(node.else_children, current_scope, diagnostics, node_file_path, component_stack=component_stack, seen_keys=seen_keys)
1873
+ continue
1874
+
1875
+ if isinstance(node, ComponentNode):
1876
+ prop_names = set()
1877
+ for key, raw_value in node.props:
1878
+ prop_names.add(key)
1879
+ analyze_interpolated_text(raw_value, current_scope, diagnostics, token=token, file_path=node_file_path, label=f"component prop `{node.name}.{key}`")
1880
+ analyze_nodes_semantics(node.children, current_scope, diagnostics, node_file_path, component_stack=component_stack, seen_keys=seen_keys)
1881
+ if node.name not in component_stack:
1882
+ try:
1883
+ component_nodes = load_component_ast(node.name)
1884
+ component_path = resolve_component_path(node.name)
1885
+ component_scope = set(current_scope)
1886
+ component_scope.update(prop_names)
1887
+ component_scope.update({"props", "children"})
1888
+ analyze_nodes_semantics(
1889
+ component_nodes,
1890
+ component_scope,
1891
+ diagnostics,
1892
+ component_path,
1893
+ component_stack=component_stack + [node.name],
1894
+ seen_keys=seen_keys,
1895
+ )
1896
+ except CompilerError:
1897
+ # Component analysis should not crash page-level semantic analysis.
1898
+ # Missing/broken components are handled elsewhere as diagnostics.
1899
+ pass
1900
+ except Exception:
1901
+ logger.exception("Unexpected error while analyzing component `%s` semantics", node.name)
1902
+ continue
1903
+
1904
+ if isinstance(node, ElementNode):
1905
+ analyze_interpolated_text(node.text, current_scope, diagnostics, token=token, file_path=node_file_path, label=f"`{node.tag}` text")
1906
+ seen_attrs = set()
1907
+ for attr_name, raw_value in node.attrs:
1908
+ if attr_name in seen_attrs:
1909
+ _append_unique_diagnostic(
1910
+ diagnostics,
1911
+ build_diagnostic(
1912
+ "warning",
1913
+ "TW2002",
1914
+ f"Duplicate attribute `{attr_name}` on `<{node.tag}>`.",
1915
+ file_path=node_file_path,
1916
+ token=token,
1917
+ suggestion="Remove the duplicate attribute to keep the final HTML predictable.",
1918
+ ),
1919
+ seen_keys,
1920
+ )
1921
+ seen_attrs.add(attr_name)
1922
+ analyze_interpolated_text(raw_value, current_scope, diagnostics, token=token, file_path=node_file_path, label=f"`{node.tag}` attribute `{attr_name}`")
1923
+ for css_name, raw_value in node.inline_style:
1924
+ analyze_interpolated_text(raw_value, current_scope, diagnostics, token=token, file_path=node_file_path, label=f"`{node.tag}` style `{css_name}`")
1925
+ for event_name, raw_handler in node.events:
1926
+ analyze_interpolated_text(raw_handler, current_scope, diagnostics, token=token, file_path=node_file_path, label=f"`{node.tag}` event `{event_name}`")
1927
+ for router_key, raw_value in (node.router or {}).items():
1928
+ analyze_interpolated_text(raw_value, current_scope, diagnostics, token=token, file_path=node_file_path, label=f"`{node.tag}` router `{router_key}`")
1929
+ analyze_nodes_semantics(node.children, current_scope, diagnostics, node_file_path, component_stack=component_stack, seen_keys=seen_keys)
1930
+
1931
+
1932
+ def analyze_page_semantics(page_ast, context, tw_path, page_info=None):
1933
+ diagnostics = []
1934
+ seen_keys = set()
1935
+ scope_names = collect_known_scope_names(context)
1936
+ if page_info and page_info.get("type") == "dynamic":
1937
+ scope_names.add(page_info.get("param", ""))
1938
+ if page_info.get("route_kind") != "single":
1939
+ scope_names.add(page_info.get("param", "") + "Segments")
1940
+
1941
+ analyze_interpolated_text(page_ast.title, scope_names, diagnostics, file_path=tw_path, label="page title")
1942
+ analyze_interpolated_text(page_ast.redirect_to, scope_names, diagnostics, file_path=tw_path, label="page redirect")
1943
+ analyze_interpolated_text(page_ast.rewrite_to, scope_names, diagnostics, file_path=tw_path, label="page rewrite")
1944
+ analyze_interpolated_text(page_ast.head.icon, scope_names, diagnostics, file_path=tw_path, label="head icon")
1945
+ for meta in page_ast.head.metas:
1946
+ for key, raw_value in meta.items():
1947
+ analyze_interpolated_text(raw_value, scope_names, diagnostics, file_path=tw_path, label=f"head meta `{key}`")
1948
+ for key, raw_value in page_ast.head.seo.items():
1949
+ analyze_interpolated_text(raw_value, scope_names, diagnostics, file_path=tw_path, label=f"head seo `{key}`")
1950
+
1951
+ analyze_nodes_semantics(page_ast.body, scope_names, diagnostics, tw_path, seen_keys=seen_keys)
1952
+ deduped = []
1953
+ dedupe_keys = set()
1954
+ for diagnostic in diagnostics:
1955
+ _append_unique_diagnostic(deduped, diagnostic, dedupe_keys)
1956
+ return deduped
1957
+
1958
+
1959
+ def eval_condition(expr, context):
1960
+ return to_bool(evaluate_expression(expr, context))
1961
+
1962
+
1963
+ def interpolate(text, context):
1964
+ if text is None or "{" not in str(text):
1965
+ return text
1966
+
1967
+ def repl(match):
1968
+ value = evaluate_expression(match.group(1), context)
1969
+ return match.group(0) if value is None else str(value)
1970
+
1971
+ # Support moustache-style placeholders: `{{brandName}}`
1972
+ # (common habit from other template engines)
1973
+ rendered = re.sub(r"\{\{([^{}]+)\}\}", repl, str(text))
1974
+
1975
+ return INTERPOLATION_RE.sub(repl, rendered)
1976
+
1977
+
1978
+ def _append_px_if_numeric(token):
1979
+ stripped = str(token).strip()
1980
+ if NUM_RE.match(stripped):
1981
+ return "0" if float(stripped) == 0 else f"{stripped}px"
1982
+ return stripped
1983
+
1984
+
1985
+ def _split_css_tokens_outside_parens(value):
1986
+ tokens = []
1987
+ current = []
1988
+ depth = 0
1989
+ for ch in value:
1990
+ if ch == "(":
1991
+ depth += 1
1992
+ current.append(ch)
1993
+ continue
1994
+ if ch == ")":
1995
+ depth = max(0, depth - 1)
1996
+ current.append(ch)
1997
+ continue
1998
+ if ch.isspace() and depth == 0:
1999
+ if current:
2000
+ tokens.append("".join(current))
2001
+ current = []
2002
+ continue
2003
+ current.append(ch)
2004
+ if current:
2005
+ tokens.append("".join(current))
2006
+ return tokens
2007
+
2008
+
2009
+ def _normalize_css_function_args(value):
2010
+ def repl(match):
2011
+ fn = match.group(1)
2012
+ args = match.group(2)
2013
+ parts = re.split(r"(\s*,\s*|\s+)", args)
2014
+ converted = []
2015
+ for part in parts:
2016
+ if not part or re.fullmatch(r"\s*,\s*|\s+", part):
2017
+ converted.append(part)
2018
+ continue
2019
+ converted.append(_append_px_if_numeric(part))
2020
+ return f"{fn}({''.join(converted)})"
2021
+
2022
+ return re.sub(r"\b(translate(?:3d|X|Y|Z)?|blur)\(([^)]*)\)", repl, value)
2023
+
2024
+
2025
+ def _normalize_border_like_value(value):
2026
+ tokens = _split_css_tokens_outside_parens(value)
2027
+ if tokens and NUM_RE.match(tokens[0]):
2028
+ tokens[0] = _append_px_if_numeric(tokens[0])
2029
+ return " ".join(tokens)
2030
+
2031
+
2032
+ def _normalize_shadow_value(value):
2033
+ tokens = _split_css_tokens_outside_parens(value)
2034
+ normalized = []
2035
+ for token in tokens:
2036
+ normalized.append(_append_px_if_numeric(token) if NUM_RE.match(token) else token)
2037
+ return " ".join(normalized)
2038
+
2039
+
2040
+ def _normalize_at_rule_selector(selector):
2041
+ if not selector.lstrip().startswith("@media"):
2042
+ return selector
2043
+ return re.sub(
2044
+ r"((?:min|max)-(?:width|height)\s*:\s*)(-?\d+(?:\.\d+)?)\b",
2045
+ lambda m: f"{m.group(1)}{_append_px_if_numeric(m.group(2))}",
2046
+ selector,
2047
+ )
2048
+
2049
+
2050
+ def finalize_css_value(css_prop, raw_value, context):
2051
+ value = interpolate(raw_value, context)
2052
+ if value is None:
2053
+ value = ""
2054
+ if css_prop in NUMERIC_CSS and isinstance(value, (int, float)):
2055
+ if css_prop == "line-height":
2056
+ return str(value)
2057
+ return _append_px_if_numeric(value)
2058
+ if isinstance(value, str):
2059
+ stripped = value.strip()
2060
+ stripped = _normalize_css_function_args(stripped)
2061
+ if css_prop == "line-height" and NUM_RE.match(stripped):
2062
+ return stripped
2063
+ if css_prop in {"border", "border-top", "border-right", "border-bottom", "border-left", "outline"}:
2064
+ return _normalize_border_like_value(stripped)
2065
+ if css_prop in {"box-shadow", "text-shadow"}:
2066
+ return _normalize_shadow_value(stripped)
2067
+ if css_prop in NUMERIC_CSS:
2068
+ if NUM_RE.match(stripped):
2069
+ return _append_px_if_numeric(stripped)
2070
+ # Multi-value numeric shorthand (eg `padding 12 18` or `margin 8 12 8 12`)
2071
+ parts = [p for p in stripped.split() if p]
2072
+ if parts and all(NUM_RE.match(p) for p in parts):
2073
+ return " ".join(_append_px_if_numeric(p) for p in parts)
2074
+ return stripped
2075
+ return str(value)
2076
+
2077
+
2078
+ def classify_known_keywords():
2079
+ return {
2080
+ "let", "if", "else", "for", "each", "in", "as", "import",
2081
+ "layout", "head", "body", "title", "load", "page",
2082
+ *EVENTS, *ROUTER_KEYS,
2083
+ }
2084
+
2085
+
2086
+ def peek(tokens, i):
2087
+ return tokens[i] if i < len(tokens) else None
2088
+
2089
+
2090
+ def collect_until_block(tokens, i):
2091
+ parts = []
2092
+ while i < len(tokens) and not (tokens[i].type == "BRACE" and tokens[i].value == "{"):
2093
+ if tokens[i].type != "NL":
2094
+ parts.append(tokens[i].value)
2095
+ i += 1
2096
+ return " ".join(parts).strip(), i
2097
+
2098
+
2099
+ def collect_until_eol(tokens, i, stop_on_block_open=False):
2100
+ """
2101
+ Collect tokens into an expression string until newline / block end.
2102
+ - STRING tokens are re-quoted to preserve valid JSON/Python literal parsing.
2103
+ - Supports multi-token literals like: ["a", "b",]
2104
+ """
2105
+ parts = []
2106
+ depth = 0 # bracket/paren nesting
2107
+ while i < len(tokens):
2108
+ tok = tokens[i]
2109
+ if tok.type == "WORD" and tok.value == ";" and depth == 0:
2110
+ break
2111
+ if tok.type == "NL" and depth == 0:
2112
+ break
2113
+ if tok.type == "BRACE":
2114
+ if tok.value == "}" and depth == 0:
2115
+ break
2116
+ if stop_on_block_open and tok.value == "{" and depth == 0:
2117
+ break
2118
+ # NOTE: `{`/`}` are reserved for TW blocks; we don't treat them as value tokens here.
2119
+ break
2120
+
2121
+ if tok.type == "WORD":
2122
+ if tok.value in {"[", "("}:
2123
+ depth += 1
2124
+ elif tok.value in {"]", ")"} and depth > 0:
2125
+ depth -= 1
2126
+ parts.append(tok.value)
2127
+ elif tok.type == "STRING":
2128
+ parts.append(json.dumps(tok.value))
2129
+ i += 1
2130
+
2131
+ expr = " ".join(parts).strip()
2132
+ return expr, i
2133
+
2134
+
2135
+ def is_statement_separator(token):
2136
+ return bool(token) and (
2137
+ token.type == "NL" or (token.type == "WORD" and token.value == ";")
2138
+ )
2139
+
2140
+
2141
+ def parse_value_token(tokens, i):
2142
+ token = peek(tokens, i)
2143
+ if not token:
2144
+ return True, i
2145
+ if is_statement_separator(token):
2146
+ return True, i + 1
2147
+ expr, j = collect_until_eol(tokens, i, stop_on_block_open=True)
2148
+ if not expr:
2149
+ return True, j
2150
+ return parse_literal_value(expr), j
2151
+
2152
+
2153
+ def unknown_property_error(token, is_component=False):
2154
+ candidates = set(CSS_PROPERTIES) | set(CSS_ALIASES) | set(HTML_ATTRIBUTES) | set(EVENTS) | set(ROUTER_KEYS)
2155
+ if is_component:
2156
+ return None
2157
+ guess = difflib.get_close_matches(token.value.lower(), sorted(candidates), n=1)
2158
+ suggestion = (
2159
+ f"Did you mean `{guess[0]}`?"
2160
+ if guess
2161
+ else "Known keys are CSS properties, HTML attributes, events, router keys, or child elements."
2162
+ )
2163
+ raise CompilerError(
2164
+ f"Unknown property or invalid child start: `{token.value}`",
2165
+ token=token,
2166
+ suggestion=suggestion,
2167
+ )
2168
+
2169
+
2170
+ def looks_like_child_start(tokens, i):
2171
+ token = peek(tokens, i)
2172
+ nxt = peek(tokens, i + 1)
2173
+ if not token or token.type != "WORD":
2174
+ return False
2175
+ if token.value.lower() in classify_known_keywords():
2176
+ return True
2177
+ if SCRIPT_PLACEHOLDER_RE.match(token.value):
2178
+ return True
2179
+ if not TAG_NAME_RE.match(token.value):
2180
+ return False
2181
+ if nxt and nxt.type == "BRACE" and nxt.value == "{":
2182
+ return True
2183
+ if nxt and nxt.type == "STRING":
2184
+ return True
2185
+ return False
2186
+
2187
+
2188
+ def extract_component_load_directive(raw, base_dir):
2189
+ """Scans a component/.tw source for a top-level `load "x.tss"` / `load @x.tss`
2190
+ line, strips it out (so the main element parser never sees it), and returns
2191
+ the parsed stylesheet (or None if nothing was loaded)."""
2192
+ m = COMPONENT_LOAD_RE.search(raw)
2193
+ if not m:
2194
+ return raw, None
2195
+ quoted, atpath = m.group(1), m.group(2)
2196
+ load_info = resolve_load_target(quoted or atpath, base_dir, location="component load")
2197
+ if load_info["kind"] != "stylesheet":
2198
+ raise CompilerError(
2199
+ f"component load: expected a stylesheet but found `{load_info['full_path']}`",
2200
+ suggestion="Inside a component file, `load` currently supports `.tss` stylesheets.",
2201
+ )
2202
+ full_path = load_info["full_path"]
2203
+ sheet = build_tss_ast_from_text(read_text_file(full_path))
2204
+ raw = COMPONENT_LOAD_RE.sub("", raw, count=1)
2205
+ return raw, sheet
2206
+
2207
+
2208
+ def load_component_ast(name):
2209
+ with _CACHE_LOCK:
2210
+ if name in _COMPONENT_AST_CACHE:
2211
+ return copy.deepcopy(_COMPONENT_AST_CACHE[name])
2212
+ collect_component_dependencies(name)
2213
+ path = resolve_component_path(name)
2214
+ if not os.path.exists(path):
2215
+ raise FileNotFoundError(f"Component not found: {path}")
2216
+ raw = read_text_file(path)
2217
+ raw, comp_sheet = extract_component_load_directive(raw, os.path.dirname(path))
2218
+ if comp_sheet is not None:
2219
+ with _CACHE_LOCK:
2220
+ _COMPONENT_STYLESHEET_PATHS[normalize_path(path)] = comp_sheet
2221
+ tokens = tokenize_tw(raw)
2222
+ nodes, _ = build_elements(tokens, 0, path, raw)
2223
+ with _CACHE_LOCK:
2224
+ _COMPONENT_AST_CACHE[name] = nodes
2225
+ return copy.deepcopy(nodes)
2226
+
2227
+
2228
+ def load_layout(name):
2229
+ with _CACHE_LOCK:
2230
+ if name in _LAYOUT_CACHE:
2231
+ return _LAYOUT_CACHE[name]
2232
+ path = os.path.join(LAYOUTS_DIR, f"{name}.tw")
2233
+ if not os.path.exists(path):
2234
+ raise FileNotFoundError(f"Layout not found: {path}")
2235
+ raw = read_text_file(path)
2236
+
2237
+ with _CACHE_LOCK:
2238
+ meta = dict(_LAYOUT_META_CACHE.get(name) or {})
2239
+ # Scan directive: `tw@responsive true|false` (line-based)
2240
+ m = LAYOUT_RESPONSIVE_RE.search(raw or "")
2241
+ if m:
2242
+ meta["responsive"] = to_bool(parse_config_scalar(m.group(1)))
2243
+ raw = LAYOUT_RESPONSIVE_RE.sub("", raw, count=1).lstrip("\n")
2244
+ with _CACHE_LOCK:
2245
+ _LAYOUT_META_CACHE[name] = meta
2246
+
2247
+ raw = resolve_layout_loads(raw, HOME_DIR)
2248
+
2249
+ with _CACHE_LOCK:
2250
+ _LAYOUT_CACHE[name] = raw
2251
+ return raw
2252
+
2253
+
2254
+ def resolve_layout_loads(raw, base_dir):
2255
+ """Lets a layout file pull in a component (header/footer etc.) or a
2256
+ stylesheet via `load "path"` / `load @path`, so it shows on every page
2257
+ that uses this layout — without the layout needing real TW parsing.
2258
+ Paths are resolved relative to `[home]/`, same as `./components/...`
2259
+ inside a component's own `load`."""
2260
+
2261
+ def repl(m):
2262
+ quoted, atpath = m.group(1), m.group(2)
2263
+ load_info = resolve_load_target(quoted or atpath, base_dir, location="layout load")
2264
+ full_path = load_info["full_path"]
2265
+
2266
+ if load_info["kind"] == "component":
2267
+ comp_raw = read_text_file(full_path)
2268
+ comp_raw, comp_sheet = extract_component_load_directive(comp_raw, os.path.dirname(full_path))
2269
+ comp_tokens = tokenize_tw(comp_raw)
2270
+ comp_nodes, _ = build_elements(comp_tokens, 0, full_path, comp_raw)
2271
+ html, _needs_router, _head = render_elements_html(comp_nodes, {}, collect_head_scripts=False)
2272
+ if comp_sheet is not None:
2273
+ html = f"<style>\n{render_css(comp_sheet, {})}</style>\n{html}"
2274
+ return html
2275
+
2276
+ if load_info["kind"] != "stylesheet":
2277
+ raise CompilerError(
2278
+ f"layout load: expected a component or stylesheet but found `{full_path}`",
2279
+ suggestion="Layouts can `load` `.tw` component files and `.tss` stylesheets.",
2280
+ )
2281
+ sheet = build_tss_ast_from_text(read_text_file(full_path))
2282
+ return f"<style>\n{render_css(sheet, {})}</style>"
2283
+
2284
+ return LAYOUT_LOAD_RE.sub(repl, raw)
2285
+
2286
+
2287
+ def get_layout_meta(name):
2288
+ # Ensures layout is loaded at least once (populates meta cache)
2289
+ with _CACHE_LOCK:
2290
+ loaded = name in _LAYOUT_CACHE
2291
+ if not loaded:
2292
+ load_layout(name)
2293
+ with _CACHE_LOCK:
2294
+ return dict(_LAYOUT_META_CACHE.get(name, {}) or {})
2295
+
2296
+
2297
+ def load_external_stylesheet(rel_path, base_dir):
2298
+ full_path = resolve_source_path(rel_path, base_dir)
2299
+ if not os.path.exists(full_path):
2300
+ raise FileNotFoundError(f"load: stylesheet not found -> {full_path}")
2301
+ return build_tss_ast_from_text(read_text_file(full_path))
2302
+
2303
+
2304
+ def write_chunk(content, ext):
2305
+ if MINIFY_OUTPUT and ext == "js":
2306
+ content = minify_js_content(content)
2307
+ digest = content_hash(content, length=8)
2308
+ with _CHUNK_LOCK:
2309
+ if digest in _CHUNK_CACHE:
2310
+ # LRU update
2311
+ _CHUNK_CACHE.move_to_end(digest)
2312
+ return _CHUNK_CACHE[digest]
2313
+ fname = f"{digest}.{ext}"
2314
+ os.makedirs(CHUNKS_DIR, exist_ok=True)
2315
+ out_path = os.path.join(CHUNKS_DIR, fname)
2316
+ try:
2317
+ # Atomic create: avoids TOCTOU between exists() and write.
2318
+ with open(out_path, "x", encoding="utf-8") as f:
2319
+ f.write(content)
2320
+ except FileExistsError:
2321
+ pass
2322
+ url = CHUNKS_URL_PREFIX + fname
2323
+ _CHUNK_CACHE[digest] = url
2324
+ _CHUNK_CACHE.move_to_end(digest)
2325
+ # Bound cache size (configurable via env)
2326
+ max_raw = os.environ.get("TW_CHUNK_CACHE_MAX", "").strip()
2327
+ max_entries = _CHUNK_CACHE_MAX_DEFAULT
2328
+ if max_raw:
2329
+ try:
2330
+ max_entries = int(max_raw)
2331
+ except ValueError:
2332
+ max_entries = _CHUNK_CACHE_MAX_DEFAULT
2333
+ max_entries = max(0, int(max_entries))
2334
+ if max_entries and len(_CHUNK_CACHE) > max_entries:
2335
+ while max_entries and len(_CHUNK_CACHE) > max_entries:
2336
+ _CHUNK_CACHE.popitem(last=False)
2337
+ return url
2338
+
2339
+
2340
+ def parse_import(tokens, i):
2341
+ i += 1
2342
+ token = peek(tokens, i)
2343
+ if not token or token.type != "STRING":
2344
+ raise CompilerError("Expected component name after `import`", token=peek(tokens, i - 1))
2345
+ name = token.value
2346
+ if not component_exists(name):
2347
+ raise CompilerError(
2348
+ f"Imported component not found: `{name}`",
2349
+ token=token,
2350
+ suggestion=f"Expected file: `{os.path.join(COMPONENTS_DIR, name + '.tw')}`",
2351
+ )
2352
+ load_component_ast(name)
2353
+ return None, i + 1
2354
+
2355
+
2356
+ def _collect_used_component_names(nodes, found=None):
2357
+ found = found or set()
2358
+ for node in nodes or []:
2359
+ if getattr(node, "tag", "") == "__component__" and getattr(node, "name", ""):
2360
+ found.add(node.name)
2361
+ _collect_used_component_names(getattr(node, "children", []) or [], found)
2362
+ _collect_used_component_names(getattr(node, "else_children", []) or [], found)
2363
+ return found
2364
+
2365
+
2366
+ def parse_let(tokens, i):
2367
+ start = peek(tokens, i)
2368
+ i += 1
2369
+ name_token = peek(tokens, i)
2370
+ if not name_token or name_token.type != "WORD":
2371
+ raise CompilerError("Expected variable name after `let`", token=start)
2372
+ i += 1
2373
+ if peek(tokens, i) and peek(tokens, i).type == "WORD" and peek(tokens, i).value == "=":
2374
+ i += 1
2375
+ value, i = parse_value_token(tokens, i)
2376
+ return LetNode(name_token.value, value), i
2377
+
2378
+
2379
+ def parse_if(tokens, i, file_path, source):
2380
+ start = peek(tokens, i)
2381
+ i += 1
2382
+ condition, i = collect_until_block(tokens, i)
2383
+ if not condition:
2384
+ raise CompilerError("Missing condition in `if` block", token=start)
2385
+ if not peek(tokens, i) or peek(tokens, i).type != "BRACE" or peek(tokens, i).value != "{":
2386
+ raise CompilerError("Expected `{` after `if` condition", token=peek(tokens, i - 1))
2387
+ i += 1
2388
+ node = IfNode(condition)
2389
+ node.children, i = build_elements(tokens, i, file_path, source, require_closing_brace=True, start_token=start)
2390
+ if peek(tokens, i) and peek(tokens, i).type == "WORD" and peek(tokens, i).value == "else":
2391
+ i += 1
2392
+ if not peek(tokens, i) or peek(tokens, i).type != "BRACE" or peek(tokens, i).value != "{":
2393
+ raise CompilerError("Expected `{` after `else`", token=peek(tokens, i - 1))
2394
+ i += 1
2395
+ node.else_children, i = build_elements(tokens, i, file_path, source, require_closing_brace=True, start_token=start)
2396
+ return node, i
2397
+
2398
+
2399
+ def parse_for(tokens, i, file_path, source):
2400
+ start = peek(tokens, i)
2401
+ i += 1
2402
+ var_token = peek(tokens, i)
2403
+ if not var_token or var_token.type != "WORD":
2404
+ raise CompilerError("Expected loop variable after `for`", token=start)
2405
+ i += 1
2406
+ if not peek(tokens, i) or peek(tokens, i).type != "WORD" or peek(tokens, i).value != "in":
2407
+ raise CompilerError("Expected `in` inside `for` loop", token=peek(tokens, i))
2408
+ i += 1
2409
+ list_expr, i = collect_until_block(tokens, i)
2410
+ if not list_expr:
2411
+ raise CompilerError("Expected iterable expression after `in`", token=peek(tokens, i - 1))
2412
+ if not peek(tokens, i) or peek(tokens, i).type != "BRACE" or peek(tokens, i).value != "{":
2413
+ raise CompilerError("Expected `{` after `for ... in ...`", token=peek(tokens, i - 1))
2414
+ i += 1
2415
+ node = ForNode(var_token.value, list_expr)
2416
+ node.children, i = build_elements(tokens, i, file_path, source, require_closing_brace=True, start_token=start)
2417
+ return node, i
2418
+
2419
+
2420
+ def parse_each(tokens, i, file_path, source):
2421
+ """
2422
+ Syntax sugar:
2423
+ each links as link { ... }
2424
+ Equivalent to:
2425
+ for link in links { ... }
2426
+ """
2427
+ start = peek(tokens, i)
2428
+ i += 1
2429
+
2430
+ # collect expr until `as`
2431
+ expr_parts = []
2432
+ while i < len(tokens):
2433
+ tok = peek(tokens, i)
2434
+ if tok.type == "NL":
2435
+ i += 1
2436
+ continue
2437
+ if tok.type == "WORD" and tok.value == "as":
2438
+ break
2439
+ if tok.type == "BRACE":
2440
+ raise CompilerError("Expected `as <var>` inside `each`", token=tok)
2441
+ expr_parts.append(tok.value if tok.type == "WORD" else json.dumps(tok.value))
2442
+ i += 1
2443
+
2444
+ if not expr_parts:
2445
+ raise CompilerError("Expected iterable expression after `each`", token=start)
2446
+ list_expr = " ".join(expr_parts).strip()
2447
+
2448
+ if not peek(tokens, i) or peek(tokens, i).type != "WORD" or peek(tokens, i).value != "as":
2449
+ raise CompilerError("Expected `as` inside `each`", token=peek(tokens, i) or start)
2450
+ i += 1
2451
+
2452
+ var_token = peek(tokens, i)
2453
+ if not var_token or var_token.type != "WORD":
2454
+ raise CompilerError("Expected loop variable after `as`", token=peek(tokens, i - 1))
2455
+ i += 1
2456
+
2457
+ if not peek(tokens, i) or peek(tokens, i).type != "BRACE" or peek(tokens, i).value != "{":
2458
+ raise CompilerError("Expected `{` after `each ... as ...`", token=peek(tokens, i - 1))
2459
+ i += 1
2460
+
2461
+ node = ForNode(var_token.value, list_expr)
2462
+ node.children, i = build_elements(tokens, i, file_path, source, require_closing_brace=True, start_token=start)
2463
+ return node, i
2464
+
2465
+
2466
+ def _try_parse_script_tag_config(raw_body: str, *, token=None):
2467
+ """
2468
+ Detect and parse:
2469
+ script { src "..." strategy afterInteractive|beforeInteractive|lazyOnload }
2470
+
2471
+ Returns (src, strategy) when the body looks like a declarative config block.
2472
+ Otherwise returns None (treat as legacy raw-JS script body).
2473
+ """
2474
+ body = str(raw_body or "")
2475
+ # Quick sniff: config blocks must mention `src`.
2476
+ if "src" not in body:
2477
+ return None
2478
+
2479
+ allowed_keys = {
2480
+ "src",
2481
+ "strategy",
2482
+ "id",
2483
+ "async",
2484
+ "defer",
2485
+ "type",
2486
+ "crossorigin",
2487
+ "integrity",
2488
+ "referrerpolicy",
2489
+ }
2490
+ cfg = {}
2491
+ try:
2492
+ inner_tokens = tokenize(body, allow_inline_scripts=False)
2493
+ except Exception:
2494
+ return None
2495
+
2496
+ i = 0
2497
+ while i < len(inner_tokens):
2498
+ tok = inner_tokens[i]
2499
+ if tok.type == "NL":
2500
+ i += 1
2501
+ continue
2502
+ if tok.type != "WORD":
2503
+ return None
2504
+ key = tok.value
2505
+ if key not in allowed_keys:
2506
+ return None
2507
+ i += 1
2508
+ if i < len(inner_tokens) and inner_tokens[i].type == "WORD" and inner_tokens[i].value == "=":
2509
+ i += 1
2510
+ if i >= len(inner_tokens):
2511
+ raise CompilerError(f"Missing value for `{key}` inside `script {{ ... }}`", token=token)
2512
+ val_tok = inner_tokens[i]
2513
+ if val_tok.type not in {"WORD", "STRING"}:
2514
+ return None
2515
+ value = val_tok.value
2516
+ i += 1
2517
+ cfg[key] = value
2518
+
2519
+ if "src" not in cfg:
2520
+ return None
2521
+ strategy = str(cfg.get("strategy") or "afterInteractive").strip()
2522
+ # Normalize (case-insensitive)
2523
+ strategy_l = strategy.lower()
2524
+ if strategy_l == "beforeinteractive":
2525
+ strategy = "beforeInteractive"
2526
+ elif strategy_l == "afterinteractive":
2527
+ strategy = "afterInteractive"
2528
+ elif strategy_l in {"lazyonload", "lazy_onload"}:
2529
+ strategy = "lazyOnload"
2530
+ else:
2531
+ raise CompilerError(
2532
+ f"Invalid `script` strategy: `{strategy}`",
2533
+ token=token,
2534
+ suggestion="Use `beforeInteractive`, `afterInteractive`, or `lazyOnload`.",
2535
+ )
2536
+ return str(cfg["src"]), strategy
2537
+
2538
+
2539
+ def parse_script_placeholder(tokens, i, file_path=None):
2540
+ token = peek(tokens, i)
2541
+ m = SCRIPT_PLACEHOLDER_RE.match(token.value)
2542
+ if not m:
2543
+ raise CompilerError("Invalid script placeholder", token=token)
2544
+ uid = int(m.group(1))
2545
+ raw_body = INLINE_SCRIPTS.get(uid, "")
2546
+ parsed = _try_parse_script_tag_config(raw_body, token=token)
2547
+ if parsed:
2548
+ src, strategy = parsed
2549
+ return ScriptTagNode(src, strategy=strategy, token=token, file_path=file_path), i + 1
2550
+ return ScriptNode(raw_body, token=token, file_path=file_path), i + 1
2551
+
2552
+
2553
+ def parse_twm_script_placeholder(tokens, i):
2554
+ token = peek(tokens, i)
2555
+ m = TWM_SCRIPT_PLACEHOLDER_RE.match(token.value)
2556
+ if not m:
2557
+ raise CompilerError("Invalid TWM script placeholder", token=token)
2558
+ uid = int(m.group(1))
2559
+ return INLINE_TWM_SCRIPTS.get(uid, ""), i + 1
2560
+
2561
+
2562
+ def parse_property_value(tokens, i):
2563
+ tok = peek(tokens, i)
2564
+ if not tok or tok.type in {"NL"}:
2565
+ return True, i + (1 if tok and tok.type == "NL" else 0)
2566
+ if tok.type == "BRACE" and tok.value == "}":
2567
+ return True, i
2568
+
2569
+ # Fast path: single token value
2570
+ nxt = peek(tokens, i + 1)
2571
+ if tok.type == "STRING" and (not nxt or nxt.type in {"NL"} or (nxt.type == "BRACE" and nxt.value == "}")):
2572
+ return tok.value, i + 1
2573
+ if tok.type == "WORD" and (not nxt or nxt.type in {"NL"} or (nxt.type == "BRACE" and nxt.value == "}")):
2574
+ return tok.value, i + 1
2575
+
2576
+ expr, j = collect_until_eol(tokens, i, stop_on_block_open=False)
2577
+ return expr, j
2578
+
2579
+
2580
+ def parse_element_or_component(tokens, i, file_path, source):
2581
+ token = peek(tokens, i)
2582
+ name = token.value
2583
+ i += 1
2584
+ text = None
2585
+ if peek(tokens, i) and peek(tokens, i).type == "STRING":
2586
+ text = peek(tokens, i).value
2587
+ i += 1
2588
+
2589
+ # Component names are expected to start with an uppercase letter.
2590
+ # This also avoids false positives on case-insensitive filesystems
2591
+ # (for example Android/Termux shared storage), where the HTML tag
2592
+ # `header` could otherwise match `Header.tw` and cause recursion.
2593
+ is_explicit_component_name = (
2594
+ bool(name)
2595
+ and (name[0].isupper() or "/" in name or "\\" in name)
2596
+ )
2597
+
2598
+ if is_explicit_component_name and component_exists(name):
2599
+ node = ComponentNode(name, token=token, file_path=file_path)
2600
+ if peek(tokens, i) and peek(tokens, i).type == "BRACE" and peek(tokens, i).value == "{":
2601
+ i += 1
2602
+ i = parse_component_block(tokens, i, node, file_path, source)
2603
+ elif text is not None:
2604
+ node.props.append(("text", text))
2605
+ return node, i
2606
+
2607
+ # Treat unknown Capitalized tags as HTML tags (auto-lowercase).
2608
+ # Example: `Section { ... }` -> `<section>...</section>`
2609
+ node = ElementNode(name.lower(), text, token=token, file_path=file_path)
2610
+ if peek(tokens, i) and peek(tokens, i).type == "BRACE" and peek(tokens, i).value == "{":
2611
+ i += 1
2612
+ i = parse_element_block(tokens, i, node, file_path, source)
2613
+ return node, i
2614
+
2615
+
2616
+ def parse_child_statement(tokens, i, file_path, source):
2617
+ token = peek(tokens, i)
2618
+ if not token:
2619
+ return None, i
2620
+ if token.type == "BRACE" and token.value == "}":
2621
+ return None, i
2622
+ if token.type == "WORD" and token.value.startswith("<") and token.value.endswith(">"):
2623
+ raise CompilerError(
2624
+ f"HTML-like tag syntax not supported: `{token.value}`",
2625
+ token=token,
2626
+ suggestion="Do not use angle brackets. In TW write: `nav { ... }`, `section { ... }` (without `<` `>`).",
2627
+ )
2628
+ if token.type == "WORD" and token.value == "import":
2629
+ return parse_import(tokens, i)
2630
+ if token.type == "WORD" and token.value == "let":
2631
+ return parse_let(tokens, i)
2632
+ if token.type == "WORD" and token.value == "if":
2633
+ return parse_if(tokens, i, file_path, source)
2634
+ if token.type == "WORD" and token.value == "for":
2635
+ return parse_for(tokens, i, file_path, source)
2636
+ if token.type == "WORD" and token.value == "each":
2637
+ return parse_each(tokens, i, file_path, source)
2638
+ if token.type == "WORD" and SCRIPT_PLACEHOLDER_RE.match(token.value):
2639
+ return parse_script_placeholder(tokens, i, file_path=file_path)
2640
+ if token.type == "WORD" and TAG_NAME_RE.match(token.value):
2641
+ return parse_element_or_component(tokens, i, file_path, source)
2642
+ raise CompilerError(f"Unexpected token: `{token.value}`", token=token)
2643
+
2644
+
2645
+ def parse_element_block(tokens, i, node, file_path, source):
2646
+ while i < len(tokens):
2647
+ token = peek(tokens, i)
2648
+ if is_statement_separator(token):
2649
+ i += 1
2650
+ continue
2651
+ if token.type == "BRACE" and token.value == "}":
2652
+ return i + 1
2653
+
2654
+ if token.type == "WORD" and token.value == "text":
2655
+ i += 1
2656
+ raw_value, i = parse_property_value(tokens, i)
2657
+ node.text = raw_value if raw_value is not True else ""
2658
+ continue
2659
+
2660
+ if token.type == "WORD" and token.value in {"let", "if", "for", "each", "import"}:
2661
+ child, i = parse_child_statement(tokens, i, file_path, source)
2662
+ if child:
2663
+ node.children.append(child)
2664
+ continue
2665
+
2666
+ if token.type == "WORD" and SCRIPT_PLACEHOLDER_RE.match(token.value):
2667
+ child, i = parse_script_placeholder(tokens, i, file_path=file_path)
2668
+ node.children.append(child)
2669
+ continue
2670
+
2671
+ if token.type == "WORD":
2672
+ # If the token looks like an element/component start (eg `input { ... }`),
2673
+ # prefer parsing it as a child node even when it collides with prop names
2674
+ # (events like `input`, router keys like `link`, etc).
2675
+ next_token = peek(tokens, i + 1)
2676
+ next_next_token = peek(tokens, i + 2)
2677
+ if next_token and next_token.type == "BRACE" and next_token.value == "{":
2678
+ child, i = parse_child_statement(tokens, i, file_path, source)
2679
+ if child:
2680
+ node.children.append(child)
2681
+ continue
2682
+ if next_token and next_token.type == "STRING" and next_next_token and next_next_token.type == "BRACE" and next_next_token.value == "{":
2683
+ child, i = parse_child_statement(tokens, i, file_path, source)
2684
+ if child:
2685
+ node.children.append(child)
2686
+ continue
2687
+
2688
+ kind = classify_known_prop(token.value)
2689
+ if kind != "unknown":
2690
+ prop_name = token.value
2691
+ i += 1
2692
+ raw_value, i = parse_property_value(tokens, i)
2693
+ if kind == "css":
2694
+ node.inline_style.append((normalize_css_prop(prop_name), raw_value))
2695
+ elif kind == "event":
2696
+ ev = prop_name.lower()
2697
+ if ev.startswith("on") and ev[2:] in EVENTS:
2698
+ ev = ev[2:]
2699
+ node.events.append((ev, raw_value))
2700
+ elif kind == "router":
2701
+ node.router[prop_name.lower()] = raw_value
2702
+ else:
2703
+ node.attrs.append((normalize_attr_name(prop_name), raw_value))
2704
+ continue
2705
+
2706
+ if looks_like_child_start(tokens, i):
2707
+ child, i = parse_child_statement(tokens, i, file_path, source)
2708
+ if child:
2709
+ node.children.append(child)
2710
+ continue
2711
+
2712
+ unknown_property_error(token)
2713
+
2714
+ raise CompilerError(f"Unexpected token inside `{node.tag}` block: `{token.value}`", token=token)
2715
+
2716
+ raise CompilerError(
2717
+ f"Missing closing `}}` for `{node.tag}` block",
2718
+ token=getattr(node, "token", None) or peek(tokens, max(len(tokens) - 1, 0)),
2719
+ file_path=getattr(node, "file_path", None) or file_path,
2720
+ suggestion="An opening `{` was found but the matching `}` is missing. Check your brace count.",
2721
+ )
2722
+
2723
+
2724
+ def parse_component_block(tokens, i, node, file_path, source):
2725
+ while i < len(tokens):
2726
+ token = peek(tokens, i)
2727
+ if is_statement_separator(token):
2728
+ i += 1
2729
+ continue
2730
+ if token.type == "BRACE" and token.value == "}":
2731
+ return i + 1
2732
+
2733
+ if token.type == "WORD" and token.value in {"let", "if", "for", "each", "import"}:
2734
+ child, i = parse_child_statement(tokens, i, file_path, source)
2735
+ if child:
2736
+ node.children.append(child)
2737
+ continue
2738
+
2739
+ if token.type == "WORD" and SCRIPT_PLACEHOLDER_RE.match(token.value):
2740
+ child, i = parse_script_placeholder(tokens, i, file_path=file_path)
2741
+ node.children.append(child)
2742
+ continue
2743
+
2744
+ if token.type == "WORD":
2745
+ next_token = peek(tokens, i + 1)
2746
+ next_next_token = peek(tokens, i + 2)
2747
+ if next_token and next_token.type == "BRACE" and next_token.value == "{":
2748
+ child, i = parse_child_statement(tokens, i, file_path, source)
2749
+ if child:
2750
+ node.children.append(child)
2751
+ continue
2752
+ if token.value in {"let", "if", "for", "each", "import"} or SCRIPT_PLACEHOLDER_RE.match(token.value):
2753
+ child, i = parse_child_statement(tokens, i, file_path, source)
2754
+ if child:
2755
+ node.children.append(child)
2756
+ continue
2757
+ if next_token and next_token.type == "STRING" and next_next_token and next_next_token.type == "BRACE" and next_next_token.value == "{":
2758
+ child, i = parse_child_statement(tokens, i, file_path, source)
2759
+ if child:
2760
+ node.children.append(child)
2761
+ continue
2762
+
2763
+ key = token.value
2764
+ i += 1
2765
+ value, i = parse_property_value(tokens, i)
2766
+ node.props.append((key, value))
2767
+ continue
2768
+
2769
+ raise CompilerError(f"Unexpected token inside component `{node.name}`: `{token.value}`", token=token)
2770
+
2771
+ raise CompilerError(
2772
+ f"Missing closing `}}` for component `{node.name}` block",
2773
+ token=getattr(node, "token", None) or peek(tokens, max(len(tokens) - 1, 0)),
2774
+ file_path=getattr(node, "file_path", None) or file_path,
2775
+ suggestion="An opening `{` was found but the matching `}` is missing. Check your brace count.",
2776
+ )
2777
+
2778
+
2779
+ def build_elements(tokens, i, file_path, source, require_closing_brace=False, start_token=None):
2780
+ nodes = []
2781
+ while i < len(tokens):
2782
+ token = peek(tokens, i)
2783
+ if is_statement_separator(token):
2784
+ i += 1
2785
+ continue
2786
+ if token.type == "BRACE" and token.value == "}":
2787
+ return nodes, i + 1
2788
+ node, i = parse_child_statement(tokens, i, file_path, source)
2789
+ if node:
2790
+ nodes.append(node)
2791
+ if require_closing_brace:
2792
+ raise CompilerError(
2793
+ "Missing closing `}` for block",
2794
+ token=start_token or peek(tokens, max(len(tokens) - 1, 0)),
2795
+ file_path=file_path,
2796
+ suggestion="An opening `{` was found but the matching `}` is missing. Check your brace count.",
2797
+ )
2798
+ return nodes, i
2799
+
2800
+
2801
+ def parse_head_block(tokens, i, head):
2802
+ while i < len(tokens):
2803
+ token = peek(tokens, i)
2804
+ if is_statement_separator(token):
2805
+ i += 1
2806
+ continue
2807
+ if token.type == "BRACE" and token.value == "}":
2808
+ return i + 1
2809
+
2810
+ if token.type == "WORD" and token.value == "meta":
2811
+ i += 1
2812
+ attrs = {}
2813
+ if not peek(tokens, i) or peek(tokens, i).type != "BRACE" or peek(tokens, i).value != "{":
2814
+ raise CompilerError("Expected `{` after `meta`", token=peek(tokens, i - 1))
2815
+ i += 1
2816
+ while i < len(tokens):
2817
+ tok = peek(tokens, i)
2818
+ if tok.type == "BRACE" and tok.value == "}":
2819
+ i += 1
2820
+ break
2821
+ if is_statement_separator(tok):
2822
+ i += 1
2823
+ continue
2824
+ if tok.type != "WORD":
2825
+ raise CompilerError("Invalid meta key", token=tok)
2826
+ key = tok.value
2827
+ i += 1
2828
+ value, i = parse_property_value(tokens, i)
2829
+ attrs[key] = value
2830
+ head.metas.append(attrs)
2831
+ continue
2832
+
2833
+ if token.type == "WORD" and token.value == "icon":
2834
+ i += 1
2835
+ value, i = parse_property_value(tokens, i)
2836
+ head.icon = value
2837
+ continue
2838
+
2839
+ if token.type == "WORD" and token.value == "seo":
2840
+ i += 1
2841
+ if not peek(tokens, i) or peek(tokens, i).type != "BRACE" or peek(tokens, i).value != "{":
2842
+ raise CompilerError("Expected `{` after `seo`", token=peek(tokens, i - 1))
2843
+ i += 1
2844
+ while i < len(tokens):
2845
+ tok = peek(tokens, i)
2846
+ if tok.type == "BRACE" and tok.value == "}":
2847
+ i += 1
2848
+ break
2849
+ if is_statement_separator(tok):
2850
+ i += 1
2851
+ continue
2852
+ if tok.type != "WORD":
2853
+ raise CompilerError("Invalid SEO key", token=tok)
2854
+ key = tok.value
2855
+ i += 1
2856
+ value, i = parse_property_value(tokens, i)
2857
+ head.seo[key] = value
2858
+ continue
2859
+
2860
+ raise CompilerError(f"Unexpected token inside `head`: `{token.value}`", token=token)
2861
+
2862
+ raise CompilerError(
2863
+ "Missing closing `}` for `head` block",
2864
+ token=peek(tokens, max(len(tokens) - 1, 0)),
2865
+ suggestion="The closing `}` for `head { ... }` appears to be missing.",
2866
+ )
2867
+
2868
+
2869
+ def parse_page_block(tokens, i, page):
2870
+ while i < len(tokens):
2871
+ token = peek(tokens, i)
2872
+ if is_statement_separator(token):
2873
+ i += 1
2874
+ continue
2875
+ if token.type == "BRACE" and token.value == "}":
2876
+ return i + 1
2877
+
2878
+ if token.type != "WORD":
2879
+ raise CompilerError("Invalid token inside `page` block", token=token)
2880
+
2881
+ key = token.value
2882
+ i += 1
2883
+ value, i = parse_property_value(tokens, i)
2884
+
2885
+ if key == "title":
2886
+ page.title = str(value)
2887
+ continue
2888
+ if key == "layout":
2889
+ # Allow multiple layout layers:
2890
+ # 1) repeated `layout` keys inside the same `page {}` block
2891
+ # 2) `layout "base,docs"` or `layout "base > docs"` style chains
2892
+ for name in parse_layout_chain(value):
2893
+ page.layouts.append(name)
2894
+ page.layout = name
2895
+ continue
2896
+ if key == "render":
2897
+ render_mode = str(value).lower()
2898
+ if render_mode not in {"static", "server", "edge"}:
2899
+ raise CompilerError(
2900
+ f"Unsupported render mode: `{render_mode}`",
2901
+ token=token,
2902
+ suggestion="Use `static`, `server`, or `edge`.",
2903
+ )
2904
+ page.render_mode = render_mode
2905
+ continue
2906
+ if key == "revalidate":
2907
+ page.revalidate = parse_config_scalar(value)
2908
+ continue
2909
+ if key == "cache_by":
2910
+ page.cache_by = re.sub(r"\s*:\s*", ":", str(value).strip())
2911
+ continue
2912
+ if key == "cache_size":
2913
+ page.cache_size = parse_config_scalar(value)
2914
+ continue
2915
+ if key == "redirect":
2916
+ page.redirect_to = str(value)
2917
+ continue
2918
+ if key == "rewrite":
2919
+ page.rewrite_to = str(value)
2920
+ continue
2921
+
2922
+ raise CompilerError(
2923
+ f"Unknown key inside `page`: `{key}`",
2924
+ token=token,
2925
+ suggestion="Use `title`, `layout`, `render`, `revalidate`, `cache_by`, `cache_size`, `redirect`, or `rewrite`.",
2926
+ )
2927
+ raise CompilerError(
2928
+ "Missing closing `}` for `page` block",
2929
+ token=peek(tokens, max(len(tokens) - 1, 0)),
2930
+ suggestion="The closing `}` for `page { ... }` appears to be missing.",
2931
+ )
2932
+
2933
+
2934
+ def build_tw_ast(tokens, base_dir, file_path, source):
2935
+ page = PageNode()
2936
+ i = 0
2937
+ while i < len(tokens):
2938
+ token = peek(tokens, i)
2939
+ if is_statement_separator(token):
2940
+ i += 1
2941
+ continue
2942
+
2943
+ if token.type == "WORD" and TWM_SCRIPT_PLACEHOLDER_RE.match(token.value):
2944
+ raw_module, i = parse_twm_script_placeholder(tokens, i)
2945
+ if raw_module and str(raw_module).strip():
2946
+ page.local_modules.append(str(raw_module))
2947
+ continue
2948
+
2949
+ # Optional: enable responsive helpers at page-level too
2950
+ if token.type == "WORD" and token.value.lower() == "tw@responsive":
2951
+ i += 1
2952
+ if peek(tokens, i) and peek(tokens, i).type == "WORD" and peek(tokens, i).value == "=":
2953
+ i += 1
2954
+ value_tok = peek(tokens, i)
2955
+ if not value_tok or value_tok.type not in {"WORD", "STRING"}:
2956
+ raise CompilerError("Expected `true` or `false` after `tw@responsive`", token=token)
2957
+ page.responsive = to_bool(parse_config_scalar(value_tok.value))
2958
+ i += 1
2959
+ continue
2960
+
2961
+ if token.type == "WORD" and token.value.upper() == "TITLE":
2962
+ i += 1
2963
+ if not peek(tokens, i) or peek(tokens, i).type != "STRING":
2964
+ raise CompilerError("Expected string after `TITLE`", token=peek(tokens, i - 1))
2965
+ page.title = peek(tokens, i).value
2966
+ i += 1
2967
+ continue
2968
+
2969
+ if token.type == "WORD" and token.value == "page":
2970
+ i += 1
2971
+ if not peek(tokens, i) or peek(tokens, i).type != "BRACE" or peek(tokens, i).value != "{":
2972
+ raise CompilerError("Expected `{` after `page`", token=peek(tokens, i - 1))
2973
+ i += 1
2974
+ i = parse_page_block(tokens, i, page)
2975
+ continue
2976
+
2977
+ if token.type == "WORD" and token.value == "layout":
2978
+ i += 1
2979
+ if not peek(tokens, i) or peek(tokens, i).type not in {"STRING", "WORD"}:
2980
+ raise CompilerError("Expected layout name after `layout`", token=peek(tokens, i - 1))
2981
+ for name in parse_layout_chain(peek(tokens, i).value):
2982
+ page.layouts.append(name)
2983
+ page.layout = name
2984
+ i += 1
2985
+ continue
2986
+
2987
+ # Explicit lifecycle hook (useEffect-equivalent, but explicit).
2988
+ # Syntax:
2989
+ # on load init <handlerName>
2990
+ if token.type == "WORD" and token.value == "on":
2991
+ t1 = peek(tokens, i + 1)
2992
+ t2 = peek(tokens, i + 2)
2993
+ t3 = peek(tokens, i + 3)
2994
+ if t1 and t2 and t3 and t1.type == "WORD" and t2.type == "WORD" and t3.type == "WORD":
2995
+ if t1.value == "load" and t2.value == "init":
2996
+ i += 3
2997
+ handler_tok = peek(tokens, i)
2998
+ if not handler_tok or handler_tok.type != "WORD":
2999
+ raise CompilerError("Expected handler name after `on load init`", token=peek(tokens, i - 1))
3000
+ page.on_load_inits.append(str(handler_tok.value))
3001
+ i += 1
3002
+ continue
3003
+
3004
+ if token.type == "WORD" and token.value == "load":
3005
+ i += 1
3006
+ path_token = peek(tokens, i)
3007
+ is_valid = path_token and (
3008
+ path_token.type == "STRING"
3009
+ or (path_token.type == "WORD" and path_token.value.startswith("@"))
3010
+ )
3011
+ if not is_valid:
3012
+ raise CompilerError("Expected path after `load`", token=peek(tokens, i - 1))
3013
+ load_info = resolve_load_target(path_token.value, base_dir, token=path_token)
3014
+ if load_info["kind"] == "json":
3015
+ try:
3016
+ key = infer_json_context_key(load_info["full_path"])
3017
+ except ValueError as e:
3018
+ raise CompilerError(str(e), token=path_token)
3019
+ page.loaded_json.append({"key": key, "path": load_info["full_path"]})
3020
+ elif load_info["kind"] == "stylesheet":
3021
+ page.loaded_sheets.append(build_tss_ast_from_text(read_text_file(load_info["full_path"])))
3022
+ elif load_info["kind"] == "module":
3023
+ page.loaded_modules.append(load_info["full_path"])
3024
+ i += 1
3025
+ continue
3026
+
3027
+ if token.type == "WORD" and token.value == "let":
3028
+ let_node, i = parse_let(tokens, i)
3029
+ page.let_vars[let_node.name] = let_node.value
3030
+ continue
3031
+
3032
+ if token.type == "WORD" and token.value == "head":
3033
+ i += 1
3034
+ if not peek(tokens, i) or peek(tokens, i).type != "BRACE" or peek(tokens, i).value != "{":
3035
+ raise CompilerError("Expected `{` after `head`", token=peek(tokens, i - 1))
3036
+ i += 1
3037
+ i = parse_head_block(tokens, i, page.head)
3038
+ continue
3039
+
3040
+ if token.type == "WORD" and token.value.upper() == "BODY":
3041
+ i += 1
3042
+ if not peek(tokens, i) or peek(tokens, i).type != "BRACE" or peek(tokens, i).value != "{":
3043
+ raise CompilerError("Expected `{` after `BODY`", token=peek(tokens, i - 1))
3044
+ i += 1
3045
+ page.body, i = build_elements(tokens, i, file_path, source, require_closing_brace=True, start_token=token)
3046
+ continue
3047
+
3048
+ if token.type == "WORD" and token.value == "import":
3049
+ _, i = parse_import(tokens, i)
3050
+ continue
3051
+
3052
+ if token.type == "WORD" and token.value == "state":
3053
+ i += 1
3054
+ if not peek(tokens, i) or peek(tokens, i).type != "BRACE" or peek(tokens, i).value != "{":
3055
+ raise CompilerError("Expected `{` after `state`", token=peek(tokens, i - 1))
3056
+ i += 1
3057
+ while i < len(tokens):
3058
+ tok = peek(tokens, i)
3059
+ if tok.type == "BRACE" and tok.value == "}":
3060
+ i += 1
3061
+ break
3062
+ if is_statement_separator(tok):
3063
+ i += 1
3064
+ continue
3065
+ if tok.type != "WORD":
3066
+ raise CompilerError("Invalid state key", token=tok)
3067
+ key = tok.value
3068
+ i += 1
3069
+ value, i = parse_value_token(tokens, i)
3070
+ page.state_vars[key] = value
3071
+ continue
3072
+
3073
+ raise CompilerError(f"Unexpected top-level token: `{token.value}`", token=token)
3074
+
3075
+ _attach_component_stylesheets(page, source)
3076
+ return page
3077
+
3078
+
3079
+ def _attach_component_stylesheets(page, source):
3080
+ """Components can `load` their own .tss file. If this page (directly or
3081
+ via nested component imports) ends up using such a component, pull that
3082
+ stylesheet in automatically -- same place page-level `load` results land."""
3083
+ seen_paths = set()
3084
+ used_names = set(IMPORT_RE.findall(source))
3085
+ used_names.update(_collect_used_component_names(getattr(page, "body", []) or []))
3086
+ for comp_name in sorted(used_names):
3087
+ try:
3088
+ dep_paths = collect_component_dependencies(comp_name)
3089
+ except CompilerError:
3090
+ continue
3091
+ for dep_path in dep_paths:
3092
+ if dep_path in _COMPONENT_STYLESHEET_PATHS and dep_path not in seen_paths:
3093
+ seen_paths.add(dep_path)
3094
+ page.loaded_sheets.append(_COMPONENT_STYLESHEET_PATHS[dep_path])
3095
+
3096
+
3097
+
3098
+ def _split_tss_body_items(body):
3099
+ items = []
3100
+ start = 0
3101
+ depth = 0
3102
+ for i, ch in enumerate(body):
3103
+ if ch == "{":
3104
+ depth += 1
3105
+ elif ch == "}":
3106
+ depth -= 1
3107
+ elif ch == "\n" and depth == 0:
3108
+ item = body[start:i].strip()
3109
+ if item:
3110
+ if item.endswith(","):
3111
+ continue
3112
+ items.append(item)
3113
+ start = i + 1
3114
+ tail = body[start:].strip()
3115
+ if tail:
3116
+ items.append(tail)
3117
+ return items
3118
+
3119
+
3120
+ def _parse_tss_rule(selector, body):
3121
+ rule = RuleNode(selector)
3122
+ for item in _split_tss_body_items(body):
3123
+ if "{" in item and item.rstrip().endswith("}"):
3124
+ nested_sheet = build_tss_ast_from_text(item)
3125
+ rule.children.extend(nested_sheet.rules)
3126
+ continue
3127
+ parts = item.split(None, 1)
3128
+ prop = parts[0].strip(":;,")
3129
+ val = parts[1].strip().strip(";") if len(parts) > 1 else "true"
3130
+ rule.declarations.append((normalize_css_prop(prop), val))
3131
+ return rule
3132
+
3133
+
3134
+ def build_tss_ast_from_text(text):
3135
+ sheet = StyleSheetNode()
3136
+ code = re.sub(r"/\\*.*?\\*/", "", text, flags=re.S)
3137
+ code = re.sub(r"//.*?$", "", code, flags=re.MULTILINE)
3138
+ i = 0
3139
+ n = len(code)
3140
+
3141
+ while i < n:
3142
+ while i < n and code[i].isspace():
3143
+ i += 1
3144
+ if i >= n:
3145
+ break
3146
+
3147
+ selector_start = i
3148
+ while i < n and code[i] != "{":
3149
+ i += 1
3150
+ selector = code[selector_start:i].strip()
3151
+ if not selector:
3152
+ break
3153
+ if i >= n or code[i] != "{":
3154
+ break
3155
+ i += 1
3156
+
3157
+ depth = 1
3158
+ body_start = i
3159
+ while i < n and depth > 0:
3160
+ if code[i] == "{":
3161
+ depth += 1
3162
+ elif code[i] == "}":
3163
+ depth -= 1
3164
+ i += 1
3165
+
3166
+ body = code[body_start:i - 1]
3167
+ sheet.rules.append(_parse_tss_rule(selector, body))
3168
+
3169
+ return sheet
3170
+
3171
+
3172
+ def render_value(value, context):
3173
+ if isinstance(value, str):
3174
+ rendered = interpolate(value, context)
3175
+ parsed = parse_literal_value(rendered)
3176
+ return parsed
3177
+ return value
3178
+
3179
+
3180
+ def html_escape(value):
3181
+ s = "" if value is None else str(value)
3182
+ return (
3183
+ s.replace("&", "&amp;")
3184
+ .replace("<", "&lt;")
3185
+ .replace(">", "&gt;")
3186
+ .replace('"', "&quot;")
3187
+ )
3188
+
3189
+
3190
+ def js_escape(value):
3191
+ return str(value).replace("\\\\", "\\\\\\\\").replace("'", "\\\\'").replace("\\n", "\\\\n")
3192
+
3193
+
3194
+ def safe_clone(value):
3195
+ # Avoid accidental cross-scope mutation for composite values
3196
+ if isinstance(value, (dict, list)):
3197
+ return copy.deepcopy(value)
3198
+ return value
3199
+
3200
+
3201
+ def render_attrs(attrs, context):
3202
+ if not attrs:
3203
+ return ""
3204
+ # Transform reactive directives (bind:, on:, show:, tw-ref, etc.)
3205
+ try:
3206
+ attrs = _get_reactivity().transform_reactive_attrs(attrs)
3207
+ except Exception:
3208
+ if os.environ.get("TW_STRICT_EVAL", "").strip().lower() in {"1", "true", "yes", "on"}:
3209
+ raise
3210
+ logger.exception("transform_reactive_attrs failed; continuing without reactive directives")
3211
+ parts = []
3212
+ for name, raw_value in attrs:
3213
+ value = render_value(raw_value, context)
3214
+ if name in {"src", "href", "poster"}:
3215
+ value = resolve_static_asset_url(value)
3216
+ elif name == "srcset" and isinstance(value, str):
3217
+ srcset_parts = []
3218
+ for part in value.split(","):
3219
+ item = part.strip()
3220
+ if not item:
3221
+ continue
3222
+ tokens = item.split()
3223
+ tokens[0] = str(resolve_static_asset_url(tokens[0]))
3224
+ srcset_parts.append(" ".join(tokens))
3225
+ value = ", ".join(srcset_parts)
3226
+ if name in BOOLEAN_ATTRS:
3227
+ if to_bool(value):
3228
+ parts.append(name)
3229
+ continue
3230
+ if value is None:
3231
+ continue
3232
+ parts.append(f'{name}="{html_escape(value)}"')
3233
+ return (" " + " ".join(parts)) if parts else ""
3234
+
3235
+
3236
+ def render_events(events, context):
3237
+ if not events:
3238
+ return ""
3239
+ parts = []
3240
+ for event_name, raw_handler in events:
3241
+ config = context.get("config", {}) if isinstance(context, dict) else {}
3242
+ allow_unsafe = to_bool(config.get("unsafe_inline_js", config.get("unsafeInlineJs", False)))
3243
+
3244
+ # Security hardening: by default allow only function identifiers (or explicit `js:`).
3245
+ # This prevents XSS where user-controlled variables inject arbitrary JS into event attrs.
3246
+ handler = interpolate(str(raw_handler), context)
3247
+ handler = "" if handler is None else str(handler).strip()
3248
+ if handler.startswith("js:"):
3249
+ if allow_unsafe:
3250
+ js = handler[3:].lstrip()
3251
+ else:
3252
+ # Ignore unless explicitly enabled in config
3253
+ continue
3254
+ elif re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", handler):
3255
+ # Route through the TW module registry to avoid relying on globals.
3256
+ # Fallback to window[handler] if not registered (compat).
3257
+ js = (
3258
+ "return (window.__twInvoke "
3259
+ f"? window.__twInvoke('{js_escape(handler)}', event) "
3260
+ f": (typeof {handler} === 'function' ? {handler}(event) : undefined))"
3261
+ )
3262
+ else:
3263
+ if allow_unsafe:
3264
+ js = handler
3265
+ else:
3266
+ continue
3267
+
3268
+ parts.append(f'on{event_name}="{html_escape(js)}"')
3269
+ return (" " + " ".join(parts)) if parts else ""
3270
+
3271
+
3272
+ def render_router(router, context):
3273
+ prefix = ""
3274
+ suffix = ""
3275
+ extra = ""
3276
+ uses_router = False
3277
+
3278
+ if "link" in router:
3279
+ href = interpolate(str(router["link"]), context)
3280
+ prefix = f'<a href="{html_escape(href)}" data-tw-link="{html_escape(href)}">'
3281
+ suffix = "</a>"
3282
+ uses_router = True
3283
+
3284
+ if "goto" in router:
3285
+ path = interpolate(str(router["goto"]), context)
3286
+ extra += f' data-tw-goto="{html_escape(path)}" onclick="return window.__twRouterGoto(event, \'{js_escape(path)}\')"'
3287
+ uses_router = True
3288
+
3289
+ return prefix, suffix, extra, uses_router
3290
+
3291
+
3292
+ def render_inline_style(style_items, context):
3293
+ if not style_items:
3294
+ return ""
3295
+ decls = []
3296
+ for prop, raw_value in style_items:
3297
+ decls.append(f"{prop}: {finalize_css_value(prop, raw_value, context)};")
3298
+ return f' style="{" ".join(decls)}"'
3299
+
3300
+
3301
+ def _format_css_selector(selector, pad):
3302
+ parts = [line.strip() for line in selector.splitlines() if line.strip()]
3303
+ if not parts:
3304
+ return selector.strip()
3305
+ return f"\n{pad}".join(parts)
3306
+
3307
+
3308
+ def _render_css_rule(rule, context, indent=0):
3309
+ pad = " " * indent
3310
+ inner_pad = " " * (indent + 1)
3311
+ selector = _format_css_selector(_normalize_at_rule_selector(rule.selector), pad)
3312
+ lines = [f"{pad}{selector} {{"]
3313
+ for prop, value in rule.declarations:
3314
+ lines.append(f"{inner_pad}{prop}: {finalize_css_value(prop, value, context)};")
3315
+ for child in rule.children:
3316
+ lines.append(_render_css_rule(child, context, indent + 1))
3317
+ lines.append(f"{pad}}}")
3318
+ return "\n".join(lines)
3319
+
3320
+
3321
+ def render_css(sheet, context=None):
3322
+ context = context or {}
3323
+ out = []
3324
+ for rule in sheet.rules:
3325
+ out.append(_render_css_rule(rule, context))
3326
+ return "\n".join(out)
3327
+
3328
+
3329
+ def render_head_extras(head, context):
3330
+ config = context.get("config", {}) if isinstance(context, dict) else {}
3331
+ site_url = str(config.get("site_url", "") or config.get("siteUrl", "") or "").rstrip("/")
3332
+ current_route = str(context.get("_tw_route", "") or "/")
3333
+ responsive = to_bool(context.get("_tw_responsive", False))
3334
+
3335
+ def absolute_url(value):
3336
+ value = render_value(value, context)
3337
+ value = resolve_static_asset_url(value)
3338
+ if not isinstance(value, str):
3339
+ return value
3340
+ if value.startswith(("http://", "https://", "//")):
3341
+ return value
3342
+ if site_url and value.startswith("/"):
3343
+ return site_url + value
3344
+ return value
3345
+
3346
+ lines = []
3347
+ if head.icon:
3348
+ lines.append(f' <link rel="icon" href="{html_escape(absolute_url(head.icon))}">')
3349
+
3350
+ for meta in head.metas:
3351
+ attrs = []
3352
+ for key, raw_value in meta.items():
3353
+ value = render_value(raw_value, context)
3354
+ if value is not None:
3355
+ attrs.append(f'{key}="{html_escape(value)}"')
3356
+ lines.append(f" <meta {' '.join(attrs)}>")
3357
+
3358
+ seo = head.seo
3359
+ mappings = {
3360
+ "description": ("meta", 'name="description"'),
3361
+ "keywords": ("meta", 'name="keywords"'),
3362
+ "canonical": ("link", 'rel="canonical"'),
3363
+ "robots": ("meta", 'name="robots"'),
3364
+ "theme-color": ("meta", 'name="theme-color"'),
3365
+ "manifest": ("link", 'rel="manifest"'),
3366
+ "og_title": ("meta", 'property="og:title"'),
3367
+ "og_image": ("meta", 'property="og:image"'),
3368
+ "og_description": ("meta", 'property="og:description"'),
3369
+ "og_type": ("meta", 'property="og:type"'),
3370
+ "og_url": ("meta", 'property="og:url"'),
3371
+ "twitter_card": ("meta", 'name="twitter:card"'),
3372
+ "twitter_title": ("meta", 'name="twitter:title"'),
3373
+ "twitter_description": ("meta", 'name="twitter:description"'),
3374
+ "twitter_image": ("meta", 'name="twitter:image"'),
3375
+ }
3376
+ for key, (tag, attr) in mappings.items():
3377
+ if key not in seo:
3378
+ continue
3379
+ value = html_escape(absolute_url(seo[key]))
3380
+ if tag == "meta":
3381
+ lines.append(f' <meta {attr} content="{value}">')
3382
+ else:
3383
+ lines.append(f' <link {attr} href="{value}">')
3384
+ if "json_ld" in seo:
3385
+ payload = render_value(seo["json_ld"], context)
3386
+ if isinstance(payload, str):
3387
+ try:
3388
+ payload = json.loads(payload)
3389
+ except json.JSONDecodeError:
3390
+ payload = payload
3391
+ if isinstance(payload, (dict, list)):
3392
+ payload = json.dumps(payload, ensure_ascii=False)
3393
+ lines.append(f' <script type="application/ld+json">{payload}</script>')
3394
+ return "\n".join(lines) + ("\n" if lines else "")
3395
+
3396
+
3397
+ def get_router_runtime_url():
3398
+ runtime_js = """window.__twRouterGoto = window.__twRouterGoto || function(event, path) {
3399
+ if (event && typeof event.preventDefault === 'function') event.preventDefault();
3400
+ window.location.href = path;
3401
+ return false;
3402
+ };"""
3403
+ return write_chunk(runtime_js, "js")
3404
+
3405
+
3406
+ def get_search_runtime_url():
3407
+ runtime_js = """(function(){
3408
+ var INDEX_URL = '/_tw/search-index.json';
3409
+ var CACHE = null;
3410
+ function norm(s){ return String(s||'').toLowerCase().trim(); }
3411
+ async function load(){
3412
+ if (CACHE) return CACHE;
3413
+ var res = await fetch(INDEX_URL, {cache:'no-store'});
3414
+ if (!res.ok) throw new Error('Search index missing: ' + INDEX_URL);
3415
+ CACHE = await res.json();
3416
+ return CACHE;
3417
+ }
3418
+ window.__twSearch = async function(query, opts){
3419
+ opts = opts || {};
3420
+ var q = norm(query);
3421
+ if (!q) return [];
3422
+ var limit = Number(opts.limit || 20);
3423
+ var items = await load();
3424
+ var parts = q.split(/\\s+/).filter(Boolean);
3425
+ var results = [];
3426
+ for (var i=0;i<items.length;i++){
3427
+ var it = items[i];
3428
+ var hay = norm((it.title||'') + ' ' + (it.content||''));
3429
+ var score = 0;
3430
+ for (var p=0;p<parts.length;p++){
3431
+ var idx = hay.indexOf(parts[p]);
3432
+ if (idx === -1) { score = 0; break; }
3433
+ score += Math.max(1, 200 - idx);
3434
+ }
3435
+ if (score > 0) results.push({route: it.route, title: it.title, excerpt: it.excerpt, score: score});
3436
+ }
3437
+ results.sort(function(a,b){ return b.score - a.score; });
3438
+ return results.slice(0, limit);
3439
+ };
3440
+ })();"""
3441
+ return write_chunk(runtime_js, "js")
3442
+
3443
+
3444
+ def build_theme_inline_script(context):
3445
+ """
3446
+ Dark/Light mode support (static friendly).
3447
+ Config keys supported:
3448
+ - theme: "system" | "dark" | "light" | "off"
3449
+ - theme_storage_key: override localStorage key (default: "tw_theme")
3450
+ Adds:
3451
+ - <html data-theme="dark|light"> + class "dark"/"light"
3452
+ - window.__twSetTheme(mode), window.__twToggleTheme()
3453
+ """
3454
+ config = context.get("config", {}) if isinstance(context, dict) else {}
3455
+ raw_mode = config.get("theme", config.get("theme_mode", config.get("themeMode", "")))
3456
+ mode = str(raw_mode or "").strip().lower()
3457
+ if not mode or mode in {"false", "0", "off", "disabled", "none", "null"}:
3458
+ return ""
3459
+ if mode not in {"system", "dark", "light"}:
3460
+ mode = "system"
3461
+ storage_key = str(config.get("theme_storage_key", config.get("themeStorageKey", "tw_theme")) or "tw_theme")
3462
+
3463
+ js = f"""(function() {{
3464
+ var STORAGE_KEY = {json.dumps(storage_key)};
3465
+ var DEFAULT_MODE = {json.dumps(mode)};
3466
+ function prefersDark() {{
3467
+ try {{
3468
+ return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
3469
+ }} catch (e) {{ return false; }}
3470
+ }}
3471
+ function resolve(mode) {{
3472
+ if (mode === 'dark' || mode === 'light') return mode;
3473
+ return prefersDark() ? 'dark' : 'light';
3474
+ }}
3475
+ function apply(resolved) {{
3476
+ var root = document.documentElement;
3477
+ root.setAttribute('data-theme', resolved);
3478
+ root.classList.toggle('dark', resolved === 'dark');
3479
+ root.classList.toggle('light', resolved === 'light');
3480
+ }}
3481
+ function getMode() {{
3482
+ var saved = null;
3483
+ try {{ saved = localStorage.getItem(STORAGE_KEY); }} catch (e) {{ saved = null; }}
3484
+ return (saved || DEFAULT_MODE || 'system');
3485
+ }}
3486
+ function setMode(mode) {{
3487
+ try {{ localStorage.setItem(STORAGE_KEY, mode); }} catch (e) {{}}
3488
+ apply(resolve(mode));
3489
+ }}
3490
+ window.__twSetTheme = function(mode) {{ setMode(String(mode || 'system').toLowerCase()); }};
3491
+ window.__twToggleTheme = function() {{
3492
+ var current = document.documentElement.getAttribute('data-theme') || resolve(getMode());
3493
+ setMode(current === 'dark' ? 'light' : 'dark');
3494
+ }};
3495
+ // Initial paint
3496
+ apply(resolve(getMode()));
3497
+ // Keep in sync with system when mode=system
3498
+ try {{
3499
+ if (window.matchMedia) {{
3500
+ var mql = window.matchMedia('(prefers-color-scheme: dark)');
3501
+ var onChange = function() {{
3502
+ if (getMode() === 'system') apply(resolve('system'));
3503
+ }};
3504
+ if (mql.addEventListener) mql.addEventListener('change', onChange);
3505
+ else if (mql.addListener) mql.addListener(onChange);
3506
+ }}
3507
+ }} catch (e) {{}}
3508
+ }})();"""
3509
+ return f" <script>{js}</script>\n"
3510
+
3511
+
3512
+ def maybe_optimize_image(node):
3513
+ if node.tag != "img":
3514
+ return
3515
+
3516
+ attr_map = {name: value for name, value in node.attrs}
3517
+ if "loading" not in attr_map:
3518
+ node.attrs.append(("loading", "lazy"))
3519
+ if "decoding" not in attr_map:
3520
+ node.attrs.append(("decoding", "async"))
3521
+
3522
+
3523
+ def _build_declarative_script_loader_js(src: str, strategy: str) -> str:
3524
+ src_json = json.dumps(str(src))
3525
+ strategy_json = json.dumps(str(strategy))
3526
+ return f"""(function(){{
3527
+ var src = {src_json};
3528
+ var strategy = {strategy_json};
3529
+ if (!src) return;
3530
+ window.__twExternalScripts = window.__twExternalScripts || Object.create(null);
3531
+ if (window.__twExternalScripts[src]) return;
3532
+ function inject(){{
3533
+ if (window.__twExternalScripts[src]) return;
3534
+ window.__twExternalScripts[src] = true;
3535
+ var s = document.createElement('script');
3536
+ s.src = src;
3537
+ s.async = true;
3538
+ (document.head || document.documentElement).appendChild(s);
3539
+ }}
3540
+ if (strategy === 'lazyOnload') {{
3541
+ window.addEventListener('load', inject, {{ once: true }});
3542
+ return;
3543
+ }}
3544
+ // afterInteractive default
3545
+ if (document.readyState === 'loading') {{
3546
+ document.addEventListener('DOMContentLoaded', inject, {{ once: true }});
3547
+ }} else {{
3548
+ inject();
3549
+ }}
3550
+ }})();"""
3551
+
3552
+
3553
+ def render_elements_html(nodes, context, indent=1, slot_children=None, collect_head_scripts: bool = True):
3554
+ pad = " " * indent
3555
+ out = []
3556
+ current_context = dict(context)
3557
+ needs_router_runtime = False
3558
+ component_stack = list(current_context.get("_tw_component_stack") or [])
3559
+ head_scripts = []
3560
+ head_seen = set()
3561
+
3562
+ for node in nodes:
3563
+ if isinstance(node, LetNode):
3564
+ # Default-props behavior inside components:
3565
+ # `let title "Fallback"` in component should NOT override a passed prop.
3566
+ if current_context.get("_tw_is_component") and node.name in current_context:
3567
+ continue
3568
+ current_context[node.name] = safe_clone(render_value(node.value, current_context))
3569
+ continue
3570
+
3571
+ if isinstance(node, ForNode):
3572
+ items = evaluate_expression(node.list_expr, current_context)
3573
+ if not isinstance(items, list):
3574
+ continue
3575
+ for item in items:
3576
+ child_context = dict(current_context)
3577
+ child_context[node.var_name] = item
3578
+ html, router_used, child_head = render_elements_html(
3579
+ node.children,
3580
+ child_context,
3581
+ indent,
3582
+ slot_children,
3583
+ collect_head_scripts=collect_head_scripts,
3584
+ )
3585
+ out.append(html)
3586
+ needs_router_runtime = needs_router_runtime or router_used
3587
+ for tag in child_head:
3588
+ head_scripts.append(tag)
3589
+ continue
3590
+
3591
+ if isinstance(node, IfNode):
3592
+ branch = node.children if eval_condition(node.condition, current_context) else node.else_children
3593
+ html, router_used, child_head = render_elements_html(
3594
+ branch,
3595
+ current_context,
3596
+ indent,
3597
+ slot_children,
3598
+ collect_head_scripts=collect_head_scripts,
3599
+ )
3600
+ out.append(html)
3601
+ needs_router_runtime = needs_router_runtime or router_used
3602
+ for tag in child_head:
3603
+ head_scripts.append(tag)
3604
+ continue
3605
+
3606
+ if isinstance(node, ScriptTagNode):
3607
+ raw_src = render_value(node.src, current_context)
3608
+ src = interpolate(raw_src, current_context) if isinstance(raw_src, str) else str(raw_src or "")
3609
+ strategy = str(getattr(node, "strategy", "afterInteractive") or "afterInteractive")
3610
+
3611
+ if strategy == "beforeInteractive":
3612
+ if collect_head_scripts and src and src not in head_seen:
3613
+ head_seen.add(src)
3614
+ head_scripts.append(f'{pad}<script src="{html_escape(src)}"></script>\n')
3615
+ elif not collect_head_scripts:
3616
+ out.append(f'{pad}<script src="{html_escape(src)}"></script>\n')
3617
+ continue
3618
+
3619
+ # afterInteractive / lazyOnload
3620
+ js = _build_declarative_script_loader_js(src, strategy)
3621
+ out.append(f"{pad}<script>{js}</script>\n")
3622
+ continue
3623
+
3624
+ if isinstance(node, ScriptNode):
3625
+ config = current_context.get("config", {}) if isinstance(current_context, dict) else {}
3626
+ allow_raw = to_bool(
3627
+ config.get(
3628
+ "allow_raw_script",
3629
+ config.get(
3630
+ "allowRawScript",
3631
+ config.get("allow_inline_js", config.get("allowInlineJs", False)),
3632
+ ),
3633
+ )
3634
+ )
3635
+ if not allow_raw:
3636
+ raise CompilerError(
3637
+ "Raw `script { ... }` blocks are disabled by default (safety).",
3638
+ token=getattr(node, "token", None),
3639
+ file_path=getattr(node, "file_path", None),
3640
+ suggestion=(
3641
+ "Use `.twm` + `load @...` + events, or use declarative "
3642
+ "`script { src \"...\" strategy afterInteractive }`. "
3643
+ "If you must allow raw scripts, set `allow_raw_script: true` in `tw.config`."
3644
+ ),
3645
+ )
3646
+ src = write_chunk(node.raw_js, "js")
3647
+ out.append(f'{pad}<script src="{src}"></script>\n')
3648
+ continue
3649
+
3650
+ if isinstance(node, ComponentNode):
3651
+ # Guard against recursive component rendering:
3652
+ # This can happen even without `import` cycles (eg a component's template
3653
+ # directly uses itself: `Card {}` inside `Card.tw`, or A -> B -> A).
3654
+ if node.name in component_stack:
3655
+ chain = " -> ".join(component_stack + [node.name])
3656
+ raise CompilerError(
3657
+ f"Recursive component render detected: {chain}",
3658
+ token=getattr(node, "token", None),
3659
+ file_path=getattr(node, "file_path", None),
3660
+ suggestion="The component is rendering itself (directly or indirectly). Break the recursion or change the structure.",
3661
+ )
3662
+ try:
3663
+ component_nodes = load_component_ast(node.name)
3664
+ except CompilerError as err:
3665
+ notes = list(err.notes or [])
3666
+ callsite_path = getattr(node, "file_path", None) or ""
3667
+ callsite_token = getattr(node, "token", None)
3668
+ target_path = err.file_path or ""
3669
+ if not target_path:
3670
+ with contextlib.suppress(Exception):
3671
+ target_path = resolve_component_path(node.name)
3672
+ if callsite_path:
3673
+ ref = f"Referenced from `{callsite_path}`"
3674
+ if callsite_token is not None:
3675
+ ref += f" at line {getattr(callsite_token, 'line', 0)}, column {getattr(callsite_token, 'col', 0)}"
3676
+ notes.append(ref)
3677
+ raise CompilerError(
3678
+ err.message,
3679
+ token=err.token or callsite_token,
3680
+ file_path=target_path or callsite_path,
3681
+ suggestion=err.suggestion,
3682
+ code=err.code,
3683
+ notes=notes,
3684
+ )
3685
+ except Exception as err:
3686
+ # Show the error at the callsite (page/component file where it was used)
3687
+ target_path = ""
3688
+ with contextlib.suppress(Exception):
3689
+ target_path = resolve_component_path(node.name)
3690
+ raise CompilerError(
3691
+ f"Failed to load component `{node.name}`: {err}",
3692
+ token=getattr(node, "token", None),
3693
+ file_path=target_path or getattr(node, "file_path", None),
3694
+ suggestion="Check whether the file exists in `[home]/components`. If you meant an HTML element, use a lowercase tag (e.g. `section`, `nav`).",
3695
+ notes=[
3696
+ (
3697
+ f"Referenced from `{getattr(node, 'file_path', '')}` "
3698
+ f"at line {getattr(getattr(node, 'token', None), 'line', 0)}, "
3699
+ f"column {getattr(getattr(node, 'token', None), 'col', 0)}"
3700
+ ).strip()
3701
+ ],
3702
+ )
3703
+ component_context = dict(current_context)
3704
+ component_context["_tw_component_stack"] = component_stack + [node.name]
3705
+ component_context["_tw_is_component"] = True
3706
+ props_dict = {}
3707
+ for key, raw_value in node.props:
3708
+ props_dict[key] = safe_clone(render_value(raw_value, current_context))
3709
+ component_context.update(props_dict)
3710
+ component_context["props"] = props_dict
3711
+ component_context["children"] = node.children
3712
+ html, router_used, child_head = render_elements_html(
3713
+ component_nodes,
3714
+ component_context,
3715
+ indent,
3716
+ node.children,
3717
+ collect_head_scripts=collect_head_scripts,
3718
+ )
3719
+ out.append(html)
3720
+ needs_router_runtime = needs_router_runtime or router_used
3721
+ for tag in child_head:
3722
+ head_scripts.append(tag)
3723
+ continue
3724
+
3725
+ if isinstance(node, ElementNode) and node.tag == "slot":
3726
+ html, router_used, child_head = render_elements_html(
3727
+ slot_children or [],
3728
+ current_context,
3729
+ indent,
3730
+ slot_children,
3731
+ collect_head_scripts=collect_head_scripts,
3732
+ )
3733
+ out.append(html)
3734
+ needs_router_runtime = needs_router_runtime or router_used
3735
+ for tag in child_head:
3736
+ head_scripts.append(tag)
3737
+ continue
3738
+
3739
+ if isinstance(node, ElementNode):
3740
+ maybe_optimize_image(node)
3741
+ attr_str = render_attrs(node.attrs, current_context)
3742
+ event_str = render_events(node.events, current_context)
3743
+ prefix, suffix, goto_str, router_used = render_router(node.router, current_context)
3744
+ style_str = render_inline_style(node.inline_style, current_context)
3745
+ full_attrs = attr_str + event_str + goto_str + style_str
3746
+ text = html_escape(interpolate(node.text, current_context)) if node.text is not None else None
3747
+
3748
+ if node.tag in VOID_TAGS:
3749
+ out.append(f"{prefix}{pad}<{node.tag}{full_attrs}>{suffix}\n")
3750
+ needs_router_runtime = needs_router_runtime or router_used
3751
+ continue
3752
+
3753
+ if node.children:
3754
+ out.append(f"{prefix}{pad}<{node.tag}{full_attrs}>\n")
3755
+ if text:
3756
+ out.append(f"{pad} {text}\n")
3757
+ html, child_router_used, child_head = render_elements_html(
3758
+ node.children,
3759
+ current_context,
3760
+ indent + 1,
3761
+ slot_children,
3762
+ collect_head_scripts=collect_head_scripts,
3763
+ )
3764
+ out.append(html)
3765
+ out.append(f"{pad}</{node.tag}>{suffix}\n")
3766
+ needs_router_runtime = needs_router_runtime or router_used or child_router_used
3767
+ for tag in child_head:
3768
+ head_scripts.append(tag)
3769
+ else:
3770
+ out.append(f"{prefix}{pad}<{node.tag}{full_attrs}>{text or ''}</{node.tag}>{suffix}\n")
3771
+ needs_router_runtime = needs_router_runtime or router_used
3772
+
3773
+ return "".join(out), needs_router_runtime, head_scripts
3774
+
3775
+
3776
+ def _build_tw_signature(page=None, context=None):
3777
+ """
3778
+ Returns (meta_tags_html, tw_data_script_html, build_comments_html)
3779
+ These injected parts form TW Framework's signature in the HTML output.
3780
+ """
3781
+ import datetime
3782
+
3783
+ build_time = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
3784
+
3785
+ # Page name / route
3786
+ route = "/"
3787
+ page_name = "index"
3788
+ render_mode = "static"
3789
+ components_used = []
3790
+
3791
+ if context and isinstance(context, dict):
3792
+ route = context.get("_tw_route", "/")
3793
+ render_mode = context.get("_tw_render_mode", "static")
3794
+
3795
+ if page is not None:
3796
+ if hasattr(page, "name"):
3797
+ page_name = page.name or "index"
3798
+ elif route and route != "/":
3799
+ page_name = route.strip("/").split("/")[-1] or "index"
3800
+
3801
+ # Collect component names from body nodes recursively
3802
+ def _collect_components(nodes, seen=None):
3803
+ if seen is None:
3804
+ seen = set()
3805
+ if not nodes:
3806
+ return seen
3807
+ for node in nodes:
3808
+ if hasattr(node, "_tw_component") and node._tw_component:
3809
+ seen.add(node._tw_component)
3810
+ if hasattr(node, "tag") and node.tag and node.tag[0].isupper():
3811
+ seen.add(node.tag)
3812
+ if hasattr(node, "children"):
3813
+ _collect_components(node.children, seen)
3814
+ return seen
3815
+
3816
+ try:
3817
+ body = getattr(page, "body", None) or []
3818
+ components_used = sorted(_collect_components(body))
3819
+ except Exception:
3820
+ logger.exception("Failed to collect components used for build signature")
3821
+ components_used = []
3822
+
3823
+ # meta + hidden markers
3824
+ meta_html = (
3825
+ ' <!-- ⚡ Built with TW Framework -->\n'
3826
+ ' <meta charset="UTF-8">\n'
3827
+ ' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
3828
+ ' <meta name="generator" content="TW Framework">\n'
3829
+ ' <div id="__TW__" style="display:none"></div>\n'
3830
+ )
3831
+
3832
+ # __TW_DATA__ JSON blob
3833
+ tw_data = {
3834
+ "page": page_name,
3835
+ "route": route,
3836
+ "render": render_mode,
3837
+ "build": build_time,
3838
+ }
3839
+ if components_used:
3840
+ tw_data["components"] = components_used
3841
+
3842
+ data_script = (
3843
+ f' <script id="__TW_DATA__" type="application/json">'
3844
+ f'{json.dumps(tw_data, separators=(",", ":"))}'
3845
+ f'</script>\n'
3846
+ )
3847
+
3848
+ # Structured HTML comments at top
3849
+ comp_str = ", ".join(components_used) if components_used else "—"
3850
+ build_comments = (
3851
+ f'<!-- [TW] Page: {page_name} | Render: {render_mode} | Route: {route} -->\n'
3852
+ f'<!-- [TW] Components: {comp_str} -->\n'
3853
+ f'<!-- [TW] Build: {build_time} -->\n'
3854
+ )
3855
+
3856
+ return meta_html, data_script, build_comments
3857
+
3858
+
3859
+ def build_default_document(title, head_extras, style_blocks, body_html, runtime_scripts_html, page=None, context=None):
3860
+ runtime_scripts = (runtime_scripts_html + "\n") if runtime_scripts_html else ""
3861
+ meta_html, data_script, build_comments = _build_tw_signature(page, context)
3862
+ return f"""{build_comments}<!DOCTYPE html>
3863
+ <html>
3864
+ <head>
3865
+ {meta_html}{data_script}{head_extras}{style_blocks}</head>
3866
+ <body>
3867
+ {body_html}{runtime_scripts}</body>
3868
+ </html>"""
3869
+
3870
+
3871
+ def build_redirect_document(title, target):
3872
+ safe_target = html_escape(target)
3873
+ return f"""<!DOCTYPE html>
3874
+ <html>
3875
+ <head>
3876
+ <meta http-equiv="refresh" content="0;url={safe_target}">
3877
+ </head>
3878
+ <body>
3879
+ <p>Redirecting to <a href="{safe_target}">{safe_target}</a>...</p>
3880
+ <script>window.location.replace({json.dumps(target)});</script>
3881
+ </body>
3882
+ </html>"""
3883
+
3884
+
3885
+ _LAYOUT_VAR_RE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*(?:\[[0-9]+\])*)\}")
3886
+
3887
+
3888
+ def interpolate_layout_template(text, context):
3889
+ if text is None or "{" not in str(text):
3890
+ return text
3891
+
3892
+ def repl(match):
3893
+ expr = match.group(1)
3894
+ value = evaluate_expression(expr, context)
3895
+ return match.group(0) if value is None else str(value)
3896
+
3897
+ return _LAYOUT_VAR_RE.sub(repl, str(text))
3898
+
3899
+
3900
+ def apply_layout_template(layout_template, title, head_extras, style_blocks, body_html, runtime_scripts_html, context, page=None):
3901
+ runtime_scripts = runtime_scripts_html or ""
3902
+ meta_html, data_script, build_comments = _build_tw_signature(page, context)
3903
+ # Inject signature into {head} placeholder
3904
+ enhanced_head = (meta_html + data_script + (head_extras or "")).rstrip()
3905
+ rendered = layout_template
3906
+ rendered = rendered.replace("{slot}", body_html)
3907
+ rendered = rendered.replace("{title}", html_escape(title))
3908
+ rendered = rendered.replace("{head}", enhanced_head)
3909
+ rendered = rendered.replace("{styles}", style_blocks.rstrip())
3910
+ rendered = rendered.replace("{scripts}", runtime_scripts)
3911
+ # Prepend build comments before <!DOCTYPE
3912
+ result = interpolate_layout_template(rendered, context)
3913
+ if result.lstrip().startswith("<!DOCTYPE") or result.lstrip().startswith("<html"):
3914
+ result = build_comments + result
3915
+ return result
3916
+
3917
+
3918
+ def _inject_reactivity_runtime(html_text: str, page_source: str, state: dict) -> str:
3919
+ """Inject TW reactivity runtime + state init into HTML before </body>."""
3920
+ try:
3921
+ from .reactivity import get_reactivity_runtime_js, build_state_init_script
3922
+ runtime_js = get_reactivity_runtime_js()
3923
+ state_init = build_state_init_script(state)
3924
+ script = f"<script>\n{runtime_js}\n{state_init}\n</script>"
3925
+ if "</body>" in html_text:
3926
+ return html_text.replace("</body>", script + "\n</body>", 1)
3927
+ return html_text + script
3928
+ except Exception:
3929
+ if os.environ.get("TW_STRICT_EVAL", "").strip().lower() in {"1", "true", "yes", "on"}:
3930
+ raise
3931
+ logger.exception("Failed to inject reactivity runtime; continuing without reactivity script")
3932
+ return html_text
3933
+
3934
+
3935
+ def _inject_on_load_inits(html_text: str, handlers) -> str:
3936
+ handlers = [str(h).strip() for h in (handlers or []) if str(h).strip()]
3937
+ if not handlers:
3938
+ return html_text
3939
+ calls = "\n".join(
3940
+ (
3941
+ " try {\n"
3942
+ f" var name = {json.dumps(name)};\n"
3943
+ " if (window.__twInvoke) window.__twInvoke(name);\n"
3944
+ " else if (typeof window[name] === 'function') window[name]();\n"
3945
+ " } catch (e) {}\n"
3946
+ )
3947
+ for name in handlers
3948
+ )
3949
+ js = f"""(function(){{
3950
+ function run(){{
3951
+ {calls}
3952
+ }}
3953
+ if (document.readyState === 'loading') {{
3954
+ document.addEventListener('DOMContentLoaded', run, {{ once: true }});
3955
+ }} else {{
3956
+ run();
3957
+ }}
3958
+ }})();"""
3959
+ script = f"<script>{js}</script>"
3960
+ if "</body>" in html_text:
3961
+ return html_text.replace("</body>", script + "\n</body>", 1)
3962
+ return html_text + script
3963
+
3964
+
3965
+ def render_html(page, context, css_href):
3966
+ if page.redirect_to:
3967
+ target = interpolate(page.redirect_to, context)
3968
+ return build_redirect_document(page.title or "Redirecting", target)
3969
+
3970
+ body_html, needs_router_runtime, head_scripts = render_elements_html(page.body, context)
3971
+ title = interpolate(page.title, context) if page.title else ""
3972
+ context = dict(context)
3973
+ context["_tw_render_mode"] = page.render_mode
3974
+ context["_tw_revalidate"] = page.revalidate
3975
+
3976
+ layout_responsive = False
3977
+ try:
3978
+ for lname in (getattr(page, "layouts", None) or []):
3979
+ if to_bool(get_layout_meta(lname).get("responsive", False)):
3980
+ layout_responsive = True
3981
+ break
3982
+ except Exception:
3983
+ logger.exception("Failed to inspect layout meta for responsive mode")
3984
+ layout_responsive = False
3985
+
3986
+ context["_tw_responsive"] = (
3987
+ to_bool(context.get("_tw_responsive", False))
3988
+ or to_bool(getattr(page, "responsive", False))
3989
+ or layout_responsive
3990
+ )
3991
+
3992
+ head_extras = "".join(head_scripts) + build_theme_inline_script(context) + render_head_extras(page.head, context)
3993
+
3994
+ style_lines = []
3995
+ if to_bool(context.get("_tw_responsive", False)):
3996
+ style_lines.append(
3997
+ " <style>\n"
3998
+ " *,*::before,*::after{box-sizing:border-box;}\n"
3999
+ " body{margin:0;min-height:100vh;}\n"
4000
+ " img,video,canvas,svg{max-width:100%;height:auto;}\n"
4001
+ " .tw-container{width:100%;margin:0 auto;padding:0 16px;}\n"
4002
+ " @media (min-width:768px){.tw-container{max-width:720px;padding:0 24px;}}\n"
4003
+ " @media (min-width:1024px){.tw-container{max-width:960px;}}\n"
4004
+ " @media (min-width:1280px){.tw-container{max-width:1140px;}}\n"
4005
+ " </style>"
4006
+ )
4007
+ if css_href:
4008
+ style_lines.append(f' <link rel="stylesheet" href="{css_href}">')
4009
+ if page.loaded_sheets:
4010
+ combined = "\n\n".join(render_css(sheet, context) for sheet in page.loaded_sheets)
4011
+ style_lines.append(f" <style>\n{combined}\n </style>")
4012
+ style_blocks = ("\n".join(style_lines) + "\n") if style_lines else ""
4013
+
4014
+ config = context.get("config", {}) if isinstance(context, dict) else {}
4015
+ search_enabled = to_bool(config.get("search", config.get("search_index", config.get("searchIndex", False))))
4016
+
4017
+ runtime_script_urls = []
4018
+ try:
4019
+ twm_sources = []
4020
+ for mod_path in getattr(page, "loaded_modules", []) or []:
4021
+ if mod_path and os.path.exists(mod_path):
4022
+ twm_sources.append({"kind": "file", "path": mod_path})
4023
+ for local_src in getattr(page, "local_modules", []) or []:
4024
+ if local_src and str(local_src).strip():
4025
+ twm_sources.append({"kind": "inline", "source": str(local_src)})
4026
+ if twm_sources:
4027
+ from .twm_parser import build_page_twm_bundle_js
4028
+ bundle_js = build_page_twm_bundle_js(twm_sources, page_source_path=getattr(page, "_tw_source_path", ""))
4029
+ runtime_script_urls.append(write_chunk(bundle_js, "js"))
4030
+ except Exception:
4031
+ if os.environ.get("TW_STRICT_EVAL", "").strip().lower() in {"1", "true", "yes", "on"}:
4032
+ raise
4033
+ logger.exception("Failed to compile `.twm` modules; continuing without TW module bundle")
4034
+ if needs_router_runtime:
4035
+ runtime_script_urls.append(get_router_runtime_url())
4036
+ if search_enabled:
4037
+ runtime_script_urls.append(get_search_runtime_url())
4038
+ runtime_scripts_html = "\n".join(f'<script src="{url}"></script>' for url in runtime_script_urls if url)
4039
+
4040
+ try:
4041
+ raw_source = read_text_file(getattr(page, "_tw_source_path", "")) if getattr(page, "_tw_source_path", "") else ""
4042
+ except (OSError, UnicodeDecodeError):
4043
+ # This can legitimately happen for in-memory renders (e.g. `tw run`) where
4044
+ # `_tw_source_path` points at a virtual filename. Treat as "no source".
4045
+ logger.debug("Failed to read page source for reactivity detection", exc_info=True)
4046
+ raw_source = ""
4047
+
4048
+ from .reactivity import has_reactivity, parse_state_block
4049
+
4050
+ reactive_enabled = bool(raw_source and has_reactivity(raw_source))
4051
+ page_state = getattr(page, "state_vars", {}) or {}
4052
+ if reactive_enabled:
4053
+ page_state.update(parse_state_block(raw_source))
4054
+
4055
+ if page.layouts:
4056
+ wrapped_body = body_html
4057
+ for inner_name in reversed(page.layouts[1:]):
4058
+ wrapped_body = apply_layout_fragment(load_layout(inner_name), wrapped_body, context)
4059
+
4060
+ layout_template = load_layout(page.layouts[0])
4061
+ layout_html = apply_layout_template(
4062
+ layout_template,
4063
+ title,
4064
+ head_extras,
4065
+ style_blocks,
4066
+ wrapped_body,
4067
+ runtime_scripts_html,
4068
+ context,
4069
+ page=page,
4070
+ )
4071
+
4072
+ if page_state or reactive_enabled:
4073
+ layout_html = _inject_reactivity_runtime(layout_html, raw_source, page_state)
4074
+ layout_html = _inject_on_load_inits(layout_html, getattr(page, "on_load_inits", []) or [])
4075
+ return layout_html
4076
+
4077
+ final_doc = build_default_document(
4078
+ title,
4079
+ head_extras,
4080
+ style_blocks,
4081
+ body_html,
4082
+ runtime_scripts_html,
4083
+ page=page,
4084
+ context=context,
4085
+ )
4086
+
4087
+ if page_state or reactive_enabled:
4088
+ final_doc = _inject_reactivity_runtime(final_doc, raw_source, page_state)
4089
+
4090
+ final_doc = _inject_on_load_inits(final_doc, getattr(page, "on_load_inits", []) or [])
4091
+ return final_doc
4092
+
4093
+
4094
+ def parse_layout_chain(raw_value):
4095
+ """
4096
+ Accept:
4097
+ - "main"
4098
+ - "base,docs"
4099
+ - "base > docs"
4100
+ Returns: list[str] (outer -> inner)
4101
+ """
4102
+ if raw_value is None:
4103
+ return []
4104
+ text = str(raw_value).strip()
4105
+ if not text:
4106
+ return []
4107
+ # Normalize separators to comma
4108
+ normalized = text.replace(">", ",")
4109
+ parts = [part.strip() for part in normalized.split(",") if part.strip()]
4110
+ return parts
4111
+
4112
+
4113
+ def apply_layout_fragment(layout_template, body_html, context):
4114
+ """
4115
+ Apply an inner (fragment) layout around body_html.
4116
+ Inner layouts should ideally NOT include <html>/<head>/<body>; they are wrappers around `{slot}`.
4117
+ """
4118
+ rendered = layout_template.replace("{slot}", body_html)
4119
+ # Inner fragments should not re-inject global document placeholders
4120
+ rendered = rendered.replace("{head}", "")
4121
+ rendered = rendered.replace("{styles}", "")
4122
+ rendered = rendered.replace("{scripts}", "")
4123
+ rendered = rendered.replace("{title}", "")
4124
+ return interpolate_layout_template(rendered, context)
4125
+
4126
+
4127
+ _LOAD_JSON_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
4128
+
4129
+
4130
+ def load_external_json(rel_path, base_dir):
4131
+ full_path = resolve_source_path(rel_path, base_dir)
4132
+ if not os.path.exists(full_path):
4133
+ raise FileNotFoundError(f"load: json not found -> {full_path}")
4134
+ with open(full_path, "r", encoding="utf-8") as f:
4135
+ return json.load(f)
4136
+
4137
+
4138
+ def infer_json_context_key(rel_path):
4139
+ base = os.path.basename(rel_path)
4140
+ stem = base[:-5] if base.lower().endswith(".json") else os.path.splitext(base)[0]
4141
+ if not _LOAD_JSON_KEY_RE.match(stem):
4142
+ raise ValueError(f"Invalid JSON context key inferred from filename: {stem}")
4143
+ return stem
4144
+
4145
+
4146
+ def load_page_data(tw_path):
4147
+ base, ext = os.path.splitext(tw_path)
4148
+ json_path = base + ".json" if ext.lower() == ".tw" else tw_path + ".json"
4149
+ if os.path.exists(json_path):
4150
+ try:
4151
+ with open(json_path, "r", encoding="utf-8") as f:
4152
+ return json.load(f)
4153
+ except Exception as err:
4154
+ log(f"⚠️ Failed to parse page data JSON: {json_path} ({err})", level="warning")
4155
+ return {}
4156
+ return {}
4157
+
4158
+
4159
+ def load_page_ast_from_file(tw_path):
4160
+ raw = read_text_file(tw_path)
4161
+ tokens = tokenize_tw(raw)
4162
+ ast = build_tw_ast(tokens, os.path.dirname(tw_path), tw_path, raw)
4163
+ ast._tw_source_path = tw_path
4164
+ return ast
4165
+
4166
+
4167
+ def load_dynamic_items(tw_path):
4168
+ base, ext = os.path.splitext(tw_path)
4169
+ json_path = base + ".json" if ext.lower() == ".tw" else tw_path + ".json"
4170
+ if not os.path.exists(json_path):
4171
+ return []
4172
+ try:
4173
+ with open(json_path, "r", encoding="utf-8") as f:
4174
+ data = json.load(f)
4175
+ except Exception as err:
4176
+ log(f"⚠️ Failed to parse dynamic route JSON: {json_path} ({err})", level="warning")
4177
+ return []
4178
+ if isinstance(data, list):
4179
+ return data
4180
+ if isinstance(data, dict) and isinstance(data.get("items"), list):
4181
+ return data["items"]
4182
+ # Schema validation: wrong shape is an actionable project error (not just "no routes").
4183
+ raise CompilerError(
4184
+ f"Dynamic route JSON has unsupported shape: {json_path}",
4185
+ file_path=json_path,
4186
+ suggestion="Expected either a JSON list (e.g. `[{\"id\":\"a\"}]`) or an object with `{\"items\": [...]}`.",
4187
+ code="TW3101",
4188
+ )
4189
+
4190
+
4191
+ def classify_dynamic_route_file(filename):
4192
+ optional_match = OPTIONAL_CATCH_ALL_FILE_RE.match(filename)
4193
+ if optional_match:
4194
+ return {"type": "dynamic", "route_kind": "optional-catch-all", "param": optional_match.group(1)}
4195
+
4196
+ catch_all_match = CATCH_ALL_FILE_RE.match(filename)
4197
+ if catch_all_match:
4198
+ return {"type": "dynamic", "route_kind": "catch-all", "param": catch_all_match.group(1)}
4199
+
4200
+ dynamic_match = DYNAMIC_FILE_RE.match(filename)
4201
+ if dynamic_match:
4202
+ return {"type": "dynamic", "route_kind": "single", "param": dynamic_match.group(1)}
4203
+
4204
+ return None
4205
+
4206
+
4207
+ def resolve_dynamic_segments(page_info, item):
4208
+ raw_value = item.get(page_info["param"], item.get("id", item.get("slug", "unknown")))
4209
+ route_kind = page_info.get("route_kind", "single")
4210
+
4211
+ if route_kind == "single":
4212
+ return [str(raw_value)]
4213
+
4214
+ if raw_value is None or raw_value == "":
4215
+ return [] if route_kind == "optional-catch-all" else ["unknown"]
4216
+
4217
+ if isinstance(raw_value, (list, tuple)):
4218
+ segments = [str(part).strip("/") for part in raw_value if str(part).strip("/")]
4219
+ else:
4220
+ segments = [part for part in str(raw_value).strip("/").split("/") if part]
4221
+
4222
+ if not segments and route_kind != "optional-catch-all":
4223
+ return ["unknown"]
4224
+ return segments
4225
+
4226
+
4227
+ def load_config():
4228
+ config = {"name": "My Site"}
4229
+ if not os.path.exists(CONFIG_FILE):
4230
+ return config
4231
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
4232
+ stack = [(-1, config)]
4233
+ for raw_line in f:
4234
+ if not raw_line.strip():
4235
+ continue
4236
+ if raw_line.lstrip().startswith("#"):
4237
+ continue
4238
+ indent = len(raw_line) - len(raw_line.lstrip(" "))
4239
+ line = raw_line.strip()
4240
+ if ":" not in line:
4241
+ continue
4242
+ key, value = line.split(":", 1)
4243
+ while len(stack) > 1 and indent <= stack[-1][0]:
4244
+ stack.pop()
4245
+ current = stack[-1][1]
4246
+ key = key.strip()
4247
+ value = value.strip()
4248
+ if value == "":
4249
+ nested = {}
4250
+ current[key] = nested
4251
+ stack.append((indent, nested))
4252
+ continue
4253
+ current[key] = parse_config_scalar(value)
4254
+ return config
4255
+
4256
+
4257
+ def discover_pages():
4258
+ pages = []
4259
+ if os.path.exists(INDEX_FILE):
4260
+ pages.append({"type": "static", "path": INDEX_FILE, "rel_dir": "", "name": "index"})
4261
+ if os.path.exists(PAGES_DIR):
4262
+ for root, _, files in os.walk(PAGES_DIR):
4263
+ rel_dir = os.path.relpath(root, PAGES_DIR)
4264
+ rel_dir = normalize_route_directory(rel_dir)
4265
+ for fname in sorted(files):
4266
+ if not fname.endswith(".tw"):
4267
+ continue
4268
+ full_path = os.path.join(root, fname)
4269
+ dynamic_meta = classify_dynamic_route_file(fname)
4270
+ if dynamic_meta:
4271
+ pages.append({
4272
+ "path": full_path,
4273
+ "rel_dir": rel_dir,
4274
+ **dynamic_meta,
4275
+ })
4276
+ else:
4277
+ pages.append({
4278
+ "type": "static",
4279
+ "path": full_path,
4280
+ "rel_dir": rel_dir,
4281
+ "name": fname[:-3],
4282
+ })
4283
+ return pages
4284
+
4285
+
4286
+ def copy_assets():
4287
+ ASSET_URL_MAP.clear()
4288
+ if not os.path.exists(ASSETS_DIR):
4289
+ return
4290
+ for sub in ("images", "js", "css", "fonts"):
4291
+ src = os.path.join(ASSETS_DIR, sub)
4292
+ dst = os.path.join(PUBLIC_ASSETS_DIR, sub)
4293
+ if not os.path.exists(src):
4294
+ continue
4295
+ os.makedirs(dst, exist_ok=True)
4296
+ for dirpath, _, filenames in os.walk(src):
4297
+ rel_dir = os.path.relpath(dirpath, src)
4298
+ rel_dir = "" if rel_dir == "." else rel_dir
4299
+ target_dir = os.path.join(dst, rel_dir) if rel_dir else dst
4300
+ os.makedirs(target_dir, exist_ok=True)
4301
+ for filename in filenames:
4302
+ full_src = os.path.join(dirpath, filename)
4303
+ if not os.path.isfile(full_src):
4304
+ continue
4305
+ with open(full_src, "rb") as f:
4306
+ digest = content_hash(f.read(), length=8)
4307
+ name, ext = os.path.splitext(filename)
4308
+ hashed_name = f"{name}.{digest}{ext}"
4309
+ full_dst = os.path.join(target_dir, hashed_name)
4310
+ shutil.copy2(full_src, full_dst)
4311
+
4312
+ rel_asset_dir = f"/assets/{sub}"
4313
+ if rel_dir:
4314
+ rel_asset_dir += "/" + rel_dir.replace(os.sep, "/")
4315
+ original_url = f"{rel_asset_dir}/{filename}"
4316
+ hashed_url = f"{rel_asset_dir}/{hashed_name}"
4317
+ ASSET_URL_MAP[original_url] = hashed_url
4318
+
4319
+
4320
+ def verify_api_isolated():
4321
+ if os.path.exists(API_DIR):
4322
+ log(" 🔒 api/ folder detected — kept server-only, not included in build output.")
4323
+
4324
+
4325
+ def read_global_stylesheet():
4326
+ if not os.path.exists(STYLE_FILE):
4327
+ return "", None
4328
+ raw = read_text_file(STYLE_FILE)
4329
+ sheet = build_tss_ast_from_text(raw)
4330
+ css_content = render_css(sheet)
4331
+ if MINIFY_OUTPUT:
4332
+ css_content = minify_css_content(css_content)
4333
+ css_url = write_chunk(css_content, "css")
4334
+ return css_url, sheet
4335
+
4336
+
4337
+ def create_base_context(page_ast, tw_path):
4338
+ context = {}
4339
+ for key, value in page_ast.let_vars.items():
4340
+ context[key] = value
4341
+ for entry in getattr(page_ast, "loaded_json", []) or []:
4342
+ if not isinstance(entry, dict):
4343
+ continue
4344
+ key = entry.get("key")
4345
+ rel_path = entry.get("path")
4346
+ if not key or not rel_path:
4347
+ continue
4348
+ try:
4349
+ payload = load_external_json(rel_path, os.path.dirname(tw_path))
4350
+ except FileNotFoundError as e:
4351
+ raise CompilerError(str(e), file_path=tw_path)
4352
+ context[key] = payload
4353
+ config = load_config()
4354
+ context["config"] = config
4355
+ context["site"] = config
4356
+ context["env"] = dict(os.environ)
4357
+ return context
4358
+
4359
+
4360
+ def build_one_page(page_info, css_url):
4361
+ tw_path = page_info["path"]
4362
+ page_ast = load_page_ast_from_file(tw_path)
4363
+
4364
+ if page_info["type"] == "static":
4365
+ config = load_config()
4366
+ pretty_urls = to_bool(config.get("pretty_urls", config.get("prettyUrls", False)))
4367
+ route_path = route_path_from_page_info(page_info)
4368
+ context = build_page_context(page_info, page_ast, tw_path, route_path=route_path)
4369
+ html = render_html(page_ast, context, css_url)
4370
+ if MINIFY_OUTPUT:
4371
+ html = minify_html_content(html)
4372
+ out_dir = os.path.join(BUILD_DIR, page_info["rel_dir"]) if page_info["rel_dir"] else BUILD_DIR
4373
+ if pretty_urls and page_info["name"] != "index":
4374
+ # /about -> dist/about/index.html (clean URLs on static hosts)
4375
+ out_dir = os.path.join(out_dir, page_info["name"])
4376
+ out_path = os.path.join(out_dir, "index.html")
4377
+ else:
4378
+ # legacy: /about -> dist/about.html
4379
+ out_path = os.path.join(out_dir, f"{page_info['name']}.html")
4380
+ os.makedirs(out_dir, exist_ok=True)
4381
+ with open(out_path, "w", encoding="utf-8") as f:
4382
+ f.write(html)
4383
+ return [out_path]
4384
+
4385
+ built_paths = []
4386
+ items = load_dynamic_items(tw_path)
4387
+ for item in items:
4388
+ if not isinstance(item, dict):
4389
+ continue
4390
+ segments = resolve_dynamic_segments(page_info, item)
4391
+ route_path = route_path_from_page_info(page_info, item=item)
4392
+ context = build_page_context(page_info, page_ast, tw_path, item=item, route_path=route_path)
4393
+ page_copy = copy.deepcopy(page_ast)
4394
+ html = render_html(page_copy, context, css_url)
4395
+ if MINIFY_OUTPUT:
4396
+ html = minify_html_content(html)
4397
+
4398
+ seg = page_info["rel_dir"] if page_info["rel_dir"] else ""
4399
+ route_parts = [BUILD_DIR]
4400
+ if seg:
4401
+ route_parts.append(seg)
4402
+ route_parts.extend(segments)
4403
+ out_dir = os.path.join(*route_parts)
4404
+ os.makedirs(out_dir, exist_ok=True)
4405
+ out_path = os.path.join(out_dir, "index.html")
4406
+ with open(out_path, "w", encoding="utf-8") as f:
4407
+ f.write(html)
4408
+ built_paths.append(out_path)
4409
+ return built_paths
4410
+
4411
+
4412
+ def parse_cli_args():
4413
+ parser = argparse.ArgumentParser(description="TW compiler build tool")
4414
+ parser.add_argument("--force", action="store_true", help="Rebuild all pages")
4415
+ parser.add_argument(
4416
+ "--workers",
4417
+ type=int,
4418
+ default=DEFAULT_WORKERS,
4419
+ help="Parallel page compilation workers",
4420
+ )
4421
+ args = parser.parse_args()
4422
+ return BuildOptions(
4423
+ force=args.force,
4424
+ workers=max(1, args.workers),
4425
+ )
4426
+
4427
+
4428
+ def get_page_manifest_entry(manifest, page_info):
4429
+ return manifest.get("pages", {}).get(page_cache_key(page_info))
4430
+
4431
+
4432
+ def should_rebuild_page(page_info, dependencies, manifest, options):
4433
+ if options.force:
4434
+ return True, "forced rebuild"
4435
+
4436
+ entry = get_page_manifest_entry(manifest, page_info)
4437
+ if not entry:
4438
+ return True, "first build"
4439
+
4440
+ signature = compute_dependency_signature(dependencies)
4441
+ if entry.get("signature") != signature:
4442
+ current_fingerprints = collect_dependency_fingerprints(dependencies)
4443
+ reason = describe_dependency_delta(entry.get("fingerprints"), current_fingerprints)
4444
+ return True, reason
4445
+
4446
+ outputs = entry.get("outputs", [])
4447
+ if not outputs or any(not os.path.exists(path) for path in outputs):
4448
+ return True, "output missing"
4449
+
4450
+ return False, "cache valid"
4451
+
4452
+
4453
+ def update_page_manifest_entry(manifest, page_info, dependencies, outputs, metadata=None):
4454
+ key = page_cache_key(page_info)
4455
+ manifest.setdefault("pages", {})
4456
+ previous = manifest["pages"].get(key, {})
4457
+ previous_outputs = set(previous.get("outputs", []))
4458
+ current_outputs = set(outputs)
4459
+ stale_outputs = sorted(previous_outputs - current_outputs)
4460
+ cleanup_outputs(stale_outputs)
4461
+ if metadata is None:
4462
+ page_ast = load_page_ast_from_file(page_info["path"])
4463
+ metadata = collect_page_metadata(page_info, page_ast=page_ast, pipeline="legacy")
4464
+ manifest["pages"][key] = {
4465
+ "type": page_info["type"],
4466
+ "path": normalize_path(page_info["path"]),
4467
+ "dependencies": sorted(normalize_path(dep) for dep in dependencies),
4468
+ "signature": compute_dependency_signature(dependencies),
4469
+ "fingerprints": collect_dependency_fingerprints(dependencies),
4470
+ "outputs": sorted(normalize_path(out) for out in outputs),
4471
+ "metadata": metadata,
4472
+ }
4473
+
4474
+
4475
+ def build_page_job(page_info, css_url):
4476
+ outputs = build_one_page(page_info, css_url)
4477
+ return {
4478
+ "page_info": page_info,
4479
+ "outputs": outputs,
4480
+ }
4481
+
4482
+
4483
+ def print_compiler_error(page_info, err, debug: bool = False):
4484
+ if isinstance(err, CompilerError) and page_info.get("path") and os.path.exists(page_info["path"]):
4485
+ raw = read_text_file(page_info["path"])
4486
+ emitter = DiagnosticEmitter(page_info["path"], raw)
4487
+ log(emitter.format(err, debug=debug), level="error")
4488
+ else:
4489
+ log(f" ❌ Error in {page_info['path']}: {err}", level="error")
4490
+
4491
+
4492
+ def main():
4493
+ options = parse_cli_args()
4494
+ config = load_config()
4495
+ log(f"🔧 Building: {config.get('name', 'My Site')}\n")
4496
+
4497
+ os.makedirs(BUILD_DIR, exist_ok=True)
4498
+ os.makedirs(PUBLIC_ASSETS_DIR, exist_ok=True)
4499
+ os.makedirs(CACHE_DIR, exist_ok=True)
4500
+ os.makedirs(MANIFEST_DIR, exist_ok=True)
4501
+ os.makedirs(COMPILER_DIR, exist_ok=True)
4502
+
4503
+ css_url, _ = read_global_stylesheet()
4504
+ manifest = load_build_manifest()
4505
+
4506
+ copy_assets()
4507
+ verify_api_isolated()
4508
+
4509
+ pages = discover_pages()
4510
+ built = 0
4511
+ skipped = 0
4512
+ current_page_keys = {page_cache_key(page) for page in pages}
4513
+ removed = remove_deleted_page_outputs(manifest, current_page_keys)
4514
+
4515
+ dependency_map = {}
4516
+ pages_to_build = []
4517
+ for page_info in pages:
4518
+ try:
4519
+ dependencies = collect_page_dependencies(page_info["path"])
4520
+ dependency_map[page_cache_key(page_info)] = dependencies
4521
+ needs_build, reason = should_rebuild_page(page_info, dependencies, manifest, options)
4522
+ if needs_build:
4523
+ pages_to_build.append(page_info)
4524
+ else:
4525
+ log(f" ⏭️ {safe_relpath(page_info['path'], PROJECT_ROOT)} ({reason})")
4526
+ skipped += 1
4527
+ except Exception as err:
4528
+ print_compiler_error(page_info, err)
4529
+
4530
+ if pages_to_build:
4531
+ with concurrent.futures.ThreadPoolExecutor(max_workers=options.workers) as executor:
4532
+ future_map = {
4533
+ executor.submit(build_page_job, page_info, css_url): page_info
4534
+ for page_info in pages_to_build
4535
+ }
4536
+ for future in concurrent.futures.as_completed(future_map):
4537
+ page_info = future_map[future]
4538
+ try:
4539
+ result = future.result()
4540
+ outputs = result["outputs"]
4541
+ update_page_manifest_entry(
4542
+ manifest,
4543
+ page_info,
4544
+ dependency_map[page_cache_key(page_info)],
4545
+ outputs,
4546
+ )
4547
+ for out_path in outputs:
4548
+ rel_out = os.path.relpath(out_path, BUILD_DIR)
4549
+ log(f" ✅ {rel_out}")
4550
+ built += 1
4551
+ except Exception as err:
4552
+ print_compiler_error(page_info, err)
4553
+
4554
+ save_build_manifest(manifest)
4555
+
4556
+ log(
4557
+ f"\n🚀 Build complete — {built} page(s) generated, "
4558
+ f"{skipped} skipped, {removed} removed → {BUILD_DIR}"
4559
+ )
4560
+
4561
+
4562
+ def _token_to_dict(token):
4563
+ if hasattr(token, "to_dict"):
4564
+ return token.to_dict()
4565
+ return {
4566
+ "type": getattr(token, "type", ""),
4567
+ "value": getattr(token, "value", ""),
4568
+ "line": getattr(token, "line", 0),
4569
+ "col": getattr(token, "col", 0),
4570
+ }
4571
+
4572
+
4573
+ def _diagnostic_to_payload(err, fallback_file_path="", *, phase=None):
4574
+ if isinstance(err, Diagnostic):
4575
+ diagnostic = err
4576
+ elif isinstance(err, CompilerError):
4577
+ diagnostic = err.to_diagnostic(fallback_file_path)
4578
+ elif isinstance(err, FileNotFoundError):
4579
+ message = str(err)
4580
+ code = "TW2404" if "Layout not found" in message else "TW2405" if "Component not found" in message else "TW2400"
4581
+ suggestion = None
4582
+ if code == "TW2404":
4583
+ suggestion = "Add the layout file to `[home]/layouts/<name>.tw`, or update the page's `layout` value."
4584
+ elif code == "TW2405":
4585
+ suggestion = "Add the component file to `[home]/components`, or fix the import name."
4586
+ diagnostic = Diagnostic(
4587
+ severity="error",
4588
+ code=code,
4589
+ message=message,
4590
+ file_path=fallback_file_path or "",
4591
+ suggestion=suggestion,
4592
+ )
4593
+ else:
4594
+ diagnostic = Diagnostic(
4595
+ severity="error",
4596
+ code="TW0000",
4597
+ message=str(err),
4598
+ file_path=fallback_file_path or "",
4599
+ )
4600
+ return {
4601
+ "severity": diagnostic.severity,
4602
+ "code": diagnostic.code,
4603
+ "message": diagnostic.message,
4604
+ "file_path": diagnostic.file_path,
4605
+ "line": diagnostic.line,
4606
+ "col": diagnostic.col,
4607
+ "end_line": getattr(diagnostic, "end_line", diagnostic.line),
4608
+ "end_col": getattr(diagnostic, "end_col", diagnostic.col),
4609
+ "suggestion": diagnostic.suggestion,
4610
+ "notes": list(diagnostic.notes or []),
4611
+ "phase": phase or getattr(diagnostic, "phase", None),
4612
+ "exception_type": getattr(diagnostic, "exception_type", None) or err.__class__.__name__,
4613
+ }
4614
+
4615
+
4616
+ def _summarize_diagnostics_payload(items):
4617
+ summary = {
4618
+ "total": 0,
4619
+ "errors": 0,
4620
+ "warnings": 0,
4621
+ "info": 0,
4622
+ "by_code": {},
4623
+ "by_phase": {},
4624
+ }
4625
+ for item in items or []:
4626
+ summary["total"] += 1
4627
+ severity = str(item.get("severity", "info") or "info").lower()
4628
+ if severity == "error":
4629
+ summary["errors"] += 1
4630
+ elif severity == "warning":
4631
+ summary["warnings"] += 1
4632
+ else:
4633
+ summary["info"] += 1
4634
+
4635
+ code = item.get("code") or "unknown"
4636
+ summary["by_code"][code] = summary["by_code"].get(code, 0) + 1
4637
+
4638
+ phase = item.get("phase") or "unspecified"
4639
+ summary["by_phase"][phase] = summary["by_phase"].get(phase, 0) + 1
4640
+ return summary
4641
+
4642
+
4643
+ def _pipeline_metadata_from_program(program, *, file_path, route_path, dependencies):
4644
+ layouts = list(program.meta.layouts or [])
4645
+ if not layouts and program.meta.layout:
4646
+ layouts = [program.meta.layout]
4647
+ raw = ""
4648
+ try:
4649
+ if file_path and file_path != "<memory>" and os.path.exists(file_path):
4650
+ raw = read_text_file(file_path)
4651
+ except (OSError, UnicodeDecodeError):
4652
+ logger.debug("Failed to read source for metadata extraction: %s", file_path, exc_info=True)
4653
+ raw = ""
4654
+ directives = extract_directives_from_source(raw, os.path.dirname(file_path)) if raw else {"imports": []}
4655
+ return {
4656
+ "pipeline": "modular",
4657
+ "route_path": route_path or "/",
4658
+ "render_mode": program.meta.render_mode,
4659
+ "revalidate": program.meta.revalidate,
4660
+ "cache_by": getattr(program.meta, "cache_by", None),
4661
+ "cache_size": getattr(program.meta, "cache_size", None),
4662
+ "layouts": layouts,
4663
+ "components": sorted(set(directives.get("imports", []))),
4664
+ "dependency_count": len(dependencies),
4665
+ }
4666
+
4667
+
4668
+ def compile_text_pipeline(text, *, base_dir=".", file_path="<memory>", context=None, css_href=None, route_path=None, capture_errors=False, dependency_paths=None):
4669
+ from . import parser as modular_parser
4670
+ from .lowering import lower_program
4671
+ from .render_html import build_runtime_context, render_program_document
4672
+ from .runtime_model import CompilerArtifacts
4673
+ from .semantic import analyze_program
4674
+
4675
+ resolved_route = route_path or "/"
4676
+ dependencies = sorted(set(normalize_path(p) for p in (dependency_paths or ([file_path] if file_path and file_path != "<memory>" else []))))
4677
+ tokens = []
4678
+ try:
4679
+ tokens = [_token_to_dict(token) for token in tokenize_tw(text)]
4680
+ except Exception as err:
4681
+ if not capture_errors:
4682
+ raise
4683
+ payload = _diagnostic_to_payload(err, file_path, phase="tokenize")
4684
+ return CompilerArtifacts(
4685
+ source_path=file_path,
4686
+ tokens=[],
4687
+ diagnostics=[payload],
4688
+ route_path=resolved_route,
4689
+ dependencies=dependencies,
4690
+ metadata={
4691
+ "pipeline": "modular",
4692
+ "route_path": resolved_route,
4693
+ "dependency_count": len(dependencies),
4694
+ "completed_phases": [],
4695
+ "diagnostic_summary": _summarize_diagnostics_payload([payload]),
4696
+ "has_output": False,
4697
+ },
4698
+ )
4699
+
4700
+ diagnostics_payload = []
4701
+ completed_phases = ["tokenize"]
4702
+ program = None
4703
+ runtime_context = {}
4704
+ ir_program = None
4705
+ html_text = None
4706
+
4707
+ try:
4708
+ program = modular_parser.parse_text(text, base_dir=base_dir, file_path=file_path)
4709
+ completed_phases.append("parse")
4710
+ except Exception as err:
4711
+ if not capture_errors:
4712
+ raise
4713
+ diagnostics_payload.append(_diagnostic_to_payload(err, file_path, phase="parse"))
4714
+
4715
+ if program is not None:
4716
+ try:
4717
+ diagnostics = analyze_program(program, context=context)
4718
+ diagnostics_payload = diagnostics.to_list()
4719
+ for item in diagnostics_payload:
4720
+ item.setdefault("phase", "analyze")
4721
+ item.setdefault("exception_type", "Diagnostic")
4722
+ completed_phases.append("analyze")
4723
+ except Exception as err:
4724
+ if not capture_errors:
4725
+ raise
4726
+ diagnostics_payload.append(_diagnostic_to_payload(err, file_path, phase="analyze"))
4727
+
4728
+ if program is not None:
4729
+ try:
4730
+ ir_program = lower_program(program)
4731
+ completed_phases.append("lower")
4732
+ except Exception as err:
4733
+ if not capture_errors:
4734
+ raise
4735
+ diagnostics_payload.append(_diagnostic_to_payload(err, file_path, phase="lower"))
4736
+
4737
+ if program is not None and ir_program is not None:
4738
+ try:
4739
+ runtime_context = build_runtime_context(program, context=context)
4740
+ runtime_context["_tw_route"] = resolved_route
4741
+ runtime_context.setdefault("request", create_request_context(resolved_route))
4742
+ html_text = render_program_document(ir_program, page_program=program, context=runtime_context, css_href=css_href)
4743
+ completed_phases.append("render")
4744
+ except Exception as err:
4745
+ if not capture_errors:
4746
+ raise
4747
+ diagnostics_payload.append(_diagnostic_to_payload(err, file_path, phase="render"))
4748
+
4749
+ metadata = (
4750
+ _pipeline_metadata_from_program(program, file_path=file_path, route_path=resolved_route, dependencies=dependencies)
4751
+ if program is not None
4752
+ else {"pipeline": "modular", "route_path": resolved_route, "dependency_count": len(dependencies)}
4753
+ )
4754
+ metadata["completed_phases"] = list(completed_phases)
4755
+ metadata["diagnostic_summary"] = _summarize_diagnostics_payload(diagnostics_payload)
4756
+ metadata["has_output"] = html_text is not None
4757
+ return CompilerArtifacts(
4758
+ source_path=file_path,
4759
+ tokens=tokens,
4760
+ ast=program.to_dict() if program is not None else None,
4761
+ diagnostics=diagnostics_payload,
4762
+ ir=ir_program.to_dict() if ir_program is not None else None,
4763
+ html=html_text,
4764
+ program=program,
4765
+ runtime_context=runtime_context,
4766
+ dependencies=dependencies,
4767
+ route_path=resolved_route,
4768
+ pipeline="modular",
4769
+ metadata=metadata,
4770
+ )
4771
+
4772
+
4773
+ def compile_file_pipeline(path, context=None, css_href=None, route_path=None, capture_errors=False):
4774
+ path = normalize_path(os.path.abspath(path))
4775
+ try:
4776
+ source = read_text_file(path)
4777
+ except Exception as err:
4778
+ if not capture_errors:
4779
+ raise
4780
+ from .runtime_model import CompilerArtifacts
4781
+ payload = _diagnostic_to_payload(err, path, phase="read")
4782
+ return CompilerArtifacts(
4783
+ source_path=path,
4784
+ diagnostics=[payload],
4785
+ dependencies=[path],
4786
+ route_path=route_path or "/",
4787
+ metadata={
4788
+ "pipeline": "modular",
4789
+ "route_path": route_path or "/",
4790
+ "dependency_count": 1,
4791
+ "completed_phases": [],
4792
+ "diagnostic_summary": _summarize_diagnostics_payload([payload]),
4793
+ "has_output": False,
4794
+ },
4795
+ )
4796
+ try:
4797
+ dependency_paths = collect_page_dependencies(path)
4798
+ except CompilerError as err:
4799
+ logger.warning("Dependency collection failed for %s: %s", path, err)
4800
+ dependency_paths = [path]
4801
+ except Exception:
4802
+ logger.exception("Unexpected error while collecting dependencies for %s", path)
4803
+ dependency_paths = [path]
4804
+ return compile_text_pipeline(
4805
+ source,
4806
+ base_dir=normalize_path(os.path.dirname(path)),
4807
+ file_path=path,
4808
+ context=context,
4809
+ css_href=css_href,
4810
+ route_path=route_path,
4811
+ capture_errors=capture_errors,
4812
+ dependency_paths=dependency_paths,
4813
+ )
4814
+
4815
+
4816
+ if __name__ == "__main__":
4817
+ main()