tw-framework 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tw_framework/__init__.py +13 -0
- tw_framework/advanced_diagnostics.py +25 -0
- tw_framework/asset_optimizer.py +30 -0
- tw_framework/ast_nodes.py +283 -0
- tw_framework/build.py +7 -0
- tw_framework/build_performance.py +26 -0
- tw_framework/build_report.py +84 -0
- tw_framework/cli.py +1258 -0
- tw_framework/code_splitting.py +76 -0
- tw_framework/common.py +22 -0
- tw_framework/compiler.py +4817 -0
- tw_framework/compiler_stats.py +51 -0
- tw_framework/dead_code.py +44 -0
- tw_framework/dependency_graph.py +91 -0
- tw_framework/deploy.py +7 -0
- tw_framework/dev.py +9 -0
- tw_framework/diagnostics.py +98 -0
- tw_framework/dynamic_imports.py +51 -0
- tw_framework/error_formatter.py +53 -0
- tw_framework/framework.py +3216 -0
- tw_framework/hydration.py +129 -0
- tw_framework/incremental_cache.py +53 -0
- tw_framework/interpreter.py +42 -0
- tw_framework/ir.py +171 -0
- tw_framework/lexer.py +52 -0
- tw_framework/lowering.py +68 -0
- tw_framework/parser.py +167 -0
- tw_framework/partial_rebuild.py +24 -0
- tw_framework/performance_analyzer.py +37 -0
- tw_framework/plugin_runtime.py +196 -0
- tw_framework/production_optimizer.py +82 -0
- tw_framework/reactivity.py +349 -0
- tw_framework/render_css.py +22 -0
- tw_framework/render_html.py +259 -0
- tw_framework/route_optimizer.py +40 -0
- tw_framework/router.py +7 -0
- tw_framework/runtime_model.py +23 -0
- tw_framework/runtime_values.py +31 -0
- tw_framework/semantic.py +162 -0
- tw_framework/server.py +532 -0
- tw_framework/signature.py +51 -0
- tw_framework/static_dynamic_auto.py +32 -0
- tw_framework/streaming.py +135 -0
- tw_framework/test_build_cli_pytest.py +1078 -0
- tw_framework/tree_shaking.py +48 -0
- tw_framework/twm_parser.py +274 -0
- tw_framework-0.2.3.dist-info/METADATA +8 -0
- tw_framework-0.2.3.dist-info/RECORD +51 -0
- tw_framework-0.2.3.dist-info/WHEEL +5 -0
- tw_framework-0.2.3.dist-info/entry_points.txt +2 -0
- tw_framework-0.2.3.dist-info/top_level.txt +1 -0
tw_framework/compiler.py
ADDED
|
@@ -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("&", "&")
|
|
3184
|
+
.replace("<", "<")
|
|
3185
|
+
.replace(">", ">")
|
|
3186
|
+
.replace('"', """)
|
|
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()
|