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/cli.py
ADDED
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
from . import compiler, framework
|
|
10
|
+
from .common import log
|
|
11
|
+
from .lexer import tokenize_file
|
|
12
|
+
from .lowering import lower_program
|
|
13
|
+
from .parser import parse_file
|
|
14
|
+
from .semantic import analyze_program
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
CLI_NAME = "tw"
|
|
18
|
+
GLOBAL_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".tw-framework")
|
|
19
|
+
GLOBAL_CONFIG_FILE = os.path.join(GLOBAL_CONFIG_DIR, "config.json")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
STARTER_FILES = {
|
|
23
|
+
"tw.config": """name: My TW Site
|
|
24
|
+
site_url: http://localhost:3000
|
|
25
|
+
description: A TW starter project
|
|
26
|
+
theme: system
|
|
27
|
+
pretty_urls: true
|
|
28
|
+
search: true
|
|
29
|
+
modular_pipeline: true
|
|
30
|
+
""",
|
|
31
|
+
".env": """SITE_NAME=My TW Site
|
|
32
|
+
API_TOKEN=change-me
|
|
33
|
+
""",
|
|
34
|
+
"middleware.tw": '''use {
|
|
35
|
+
match "/dashboard/**"
|
|
36
|
+
auth "session" "/"
|
|
37
|
+
header "X-Frame-Options" "DENY"
|
|
38
|
+
}
|
|
39
|
+
''',
|
|
40
|
+
"[home]/index.tw": '''page {
|
|
41
|
+
title "My TW Site"
|
|
42
|
+
layout "main"
|
|
43
|
+
render static
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let siteName = "My TW Site"
|
|
47
|
+
let heroTitle = "Build websites in TW"
|
|
48
|
+
let heroText = "Write .tw, .tss and .ts. Run dev. Ship fast."
|
|
49
|
+
|
|
50
|
+
head {
|
|
51
|
+
seo {
|
|
52
|
+
description "A TW starter project"
|
|
53
|
+
og_title "{siteName}"
|
|
54
|
+
og_description "{heroText}"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
BODY {
|
|
59
|
+
import "Hero"
|
|
60
|
+
import "ThemeToggle"
|
|
61
|
+
|
|
62
|
+
ThemeToggle {}
|
|
63
|
+
Hero {
|
|
64
|
+
title "{heroTitle}"
|
|
65
|
+
text "{heroText}"
|
|
66
|
+
ctaText "Open About Page"
|
|
67
|
+
ctaLink "/about"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
''',
|
|
71
|
+
"[home]/pages/about.tw": '''page {
|
|
72
|
+
title "About"
|
|
73
|
+
layout "main"
|
|
74
|
+
render static
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
BODY {
|
|
78
|
+
div {
|
|
79
|
+
class "page"
|
|
80
|
+
h1 "About this project"
|
|
81
|
+
p "This page is written in TW."
|
|
82
|
+
a {
|
|
83
|
+
href "/"
|
|
84
|
+
text "Back home"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
''',
|
|
89
|
+
"[home]/pages/counter.tw": '''page {
|
|
90
|
+
title "Counter Demo"
|
|
91
|
+
layout "main"
|
|
92
|
+
render static
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
state {
|
|
96
|
+
count 0
|
|
97
|
+
name "World"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
BODY {
|
|
101
|
+
div {
|
|
102
|
+
class "page"
|
|
103
|
+
h1 "TW Reactive Counter"
|
|
104
|
+
|
|
105
|
+
p { tw-text "count" }
|
|
106
|
+
|
|
107
|
+
button {
|
|
108
|
+
on:click "__tw.set(\'count\', __tw.get(\'count\') + 1)"
|
|
109
|
+
class "button"
|
|
110
|
+
text "+"
|
|
111
|
+
}
|
|
112
|
+
button {
|
|
113
|
+
on:click "__tw.set(\'count\', __tw.get(\'count\') - 1)"
|
|
114
|
+
class "button"
|
|
115
|
+
text "-"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
hr {}
|
|
119
|
+
p "Your name:"
|
|
120
|
+
input {
|
|
121
|
+
type "text"
|
|
122
|
+
bind:value "name"
|
|
123
|
+
placeholder "Type here..."
|
|
124
|
+
}
|
|
125
|
+
p { tw-text "\'Hello, \' + name + \'!\'"}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
''',
|
|
129
|
+
"[home]/pages/contact.tw": '''page {
|
|
130
|
+
title "Contact"
|
|
131
|
+
layout "main"
|
|
132
|
+
render static
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
BODY {
|
|
136
|
+
import "ContactForm"
|
|
137
|
+
ContactForm {
|
|
138
|
+
action "/api/contact"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
''',
|
|
142
|
+
"[home]/pages/search.tw": '''page {
|
|
143
|
+
title "Search"
|
|
144
|
+
layout "main"
|
|
145
|
+
render static
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
BODY {
|
|
149
|
+
div {
|
|
150
|
+
class "page"
|
|
151
|
+
h1 "Search"
|
|
152
|
+
input {
|
|
153
|
+
id "tw-search"
|
|
154
|
+
type "search"
|
|
155
|
+
placeholder "Type to search..."
|
|
156
|
+
input "__twSearchInput"
|
|
157
|
+
}
|
|
158
|
+
div { id "tw-search-results" }
|
|
159
|
+
script {
|
|
160
|
+
window.__twSearchInput = async function(event) {
|
|
161
|
+
try {
|
|
162
|
+
var q = (event && event.target && event.target.value) ? event.target.value : '';
|
|
163
|
+
var results = await window.__twSearch(q, {limit: 15});
|
|
164
|
+
var root = document.getElementById('tw-search-results');
|
|
165
|
+
if (!root) return;
|
|
166
|
+
if (!q) { root.innerHTML = ''; return; }
|
|
167
|
+
root.innerHTML = results.map(function(r){
|
|
168
|
+
var href = r.route || '/';
|
|
169
|
+
return '<div style=\"padding:10px 0;border-bottom:1px solid var(--tw-border)\">' +
|
|
170
|
+
'<a href=\"' + href + '\" style=\"color:inherit;text-decoration:none;font-weight:700\">' +
|
|
171
|
+
(r.title || href) + '</a>' +
|
|
172
|
+
'<div style=\"opacity:.8;font-size:14px\">' + (r.excerpt || '') + '</div>' +
|
|
173
|
+
'</div>';
|
|
174
|
+
}).join('');
|
|
175
|
+
} catch (e) {}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
''',
|
|
181
|
+
"[home]/pages/dashboard.tw": '''page {
|
|
182
|
+
title "Dashboard"
|
|
183
|
+
layout "main"
|
|
184
|
+
render server
|
|
185
|
+
revalidate 60
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
BODY {
|
|
189
|
+
div {
|
|
190
|
+
class "page"
|
|
191
|
+
h1 "Private dashboard"
|
|
192
|
+
p "Protected by middleware.tw cookie auth."
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
''',
|
|
196
|
+
"[home]/api/users/route.twm": '''fn get(request) {
|
|
197
|
+
return {
|
|
198
|
+
status: 200,
|
|
199
|
+
json: [{ id: 1, name: "Ada" }]
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
fn post(request) {
|
|
204
|
+
return {
|
|
205
|
+
status: 201,
|
|
206
|
+
json: { ok: true, source: request.body || {} }
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
''',
|
|
210
|
+
"[home]/api/contact/route.twm": '''fn post(request) {
|
|
211
|
+
return {
|
|
212
|
+
status: 200,
|
|
213
|
+
json: { ok: true, message: "Thanks!", received: request.body || {} }
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
''',
|
|
217
|
+
"[home]/components/Hero.tw": '''section {
|
|
218
|
+
class "hero"
|
|
219
|
+
h1 "{title}"
|
|
220
|
+
p "{text}"
|
|
221
|
+
a {
|
|
222
|
+
href "{ctaLink}"
|
|
223
|
+
class "button"
|
|
224
|
+
text "{ctaText}"
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
''',
|
|
228
|
+
"[home]/components/ThemeToggle.tw": '''button {
|
|
229
|
+
class "theme-toggle"
|
|
230
|
+
click "__twToggleTheme"
|
|
231
|
+
aria-label "Toggle theme"
|
|
232
|
+
text "Toggle theme"
|
|
233
|
+
}
|
|
234
|
+
''',
|
|
235
|
+
"[home]/components/ContactForm.tw": '''form {
|
|
236
|
+
class "contact-form"
|
|
237
|
+
method "post"
|
|
238
|
+
action "{action}"
|
|
239
|
+
input {
|
|
240
|
+
type "text"
|
|
241
|
+
name "name"
|
|
242
|
+
placeholder "Your name"
|
|
243
|
+
required true
|
|
244
|
+
}
|
|
245
|
+
input {
|
|
246
|
+
type "email"
|
|
247
|
+
name "email"
|
|
248
|
+
placeholder "Email"
|
|
249
|
+
required true
|
|
250
|
+
}
|
|
251
|
+
textarea {
|
|
252
|
+
name "message"
|
|
253
|
+
placeholder "Message"
|
|
254
|
+
rows 5
|
|
255
|
+
required true
|
|
256
|
+
}
|
|
257
|
+
button {
|
|
258
|
+
type "submit"
|
|
259
|
+
class "button"
|
|
260
|
+
text "Send"
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
''',
|
|
264
|
+
"[home]/layouts/main.tw": """<!DOCTYPE html>
|
|
265
|
+
<html>
|
|
266
|
+
<head>
|
|
267
|
+
{head}
|
|
268
|
+
{styles}
|
|
269
|
+
</head>
|
|
270
|
+
<body>
|
|
271
|
+
{slot}
|
|
272
|
+
{scripts}
|
|
273
|
+
</body>
|
|
274
|
+
</html>
|
|
275
|
+
""",
|
|
276
|
+
"[home]/style.tss": '''body {
|
|
277
|
+
margin 0
|
|
278
|
+
font-family system-ui, sans-serif
|
|
279
|
+
background var(--tw-bg)
|
|
280
|
+
color var(--tw-fg)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
:root[data-theme="dark"] {
|
|
284
|
+
--tw-bg #0f172a
|
|
285
|
+
--tw-fg #e2e8f0
|
|
286
|
+
--tw-card rgba(15, 23, 42, 0.7)
|
|
287
|
+
--tw-border rgba(148, 163, 184, 0.25)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
:root[data-theme="light"] {
|
|
291
|
+
--tw-bg #ffffff
|
|
292
|
+
--tw-fg #0b1220
|
|
293
|
+
--tw-card rgba(255, 255, 255, 0.8)
|
|
294
|
+
--tw-border rgba(2, 6, 23, 0.12)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.hero {
|
|
298
|
+
min-height 100
|
|
299
|
+
padding 64
|
|
300
|
+
display flex
|
|
301
|
+
flex-direction column
|
|
302
|
+
gap 16
|
|
303
|
+
justify-content center
|
|
304
|
+
align-items flex-start
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.button {
|
|
308
|
+
display inline-block
|
|
309
|
+
margin-top 12
|
|
310
|
+
padding 12 18
|
|
311
|
+
border-radius 12
|
|
312
|
+
background #38bdf8
|
|
313
|
+
color #082f49
|
|
314
|
+
text-decoration none
|
|
315
|
+
font-weight 700
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.theme-toggle {
|
|
319
|
+
position fixed
|
|
320
|
+
top 16
|
|
321
|
+
right 16
|
|
322
|
+
padding 10 12
|
|
323
|
+
border-radius 12
|
|
324
|
+
border 1 solid var(--tw-border)
|
|
325
|
+
background var(--tw-card)
|
|
326
|
+
color var(--tw-fg)
|
|
327
|
+
cursor pointer
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.contact-form {
|
|
331
|
+
display flex
|
|
332
|
+
flex-direction column
|
|
333
|
+
gap 12
|
|
334
|
+
padding 48
|
|
335
|
+
max-width 680
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.contact-form input {
|
|
339
|
+
padding 12 12
|
|
340
|
+
border-radius 12
|
|
341
|
+
border 1 solid var(--tw-border)
|
|
342
|
+
background var(--tw-card)
|
|
343
|
+
color var(--tw-fg)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.contact-form textarea {
|
|
347
|
+
padding 12 12
|
|
348
|
+
border-radius 12
|
|
349
|
+
border 1 solid var(--tw-border)
|
|
350
|
+
background var(--tw-card)
|
|
351
|
+
color var(--tw-fg)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.page {
|
|
355
|
+
padding 48
|
|
356
|
+
}
|
|
357
|
+
''',
|
|
358
|
+
".gitignore": """.tw/
|
|
359
|
+
.tw-cache/
|
|
360
|
+
dist/
|
|
361
|
+
__pycache__/
|
|
362
|
+
*.pyc
|
|
363
|
+
""",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def ensure_dir(path):
|
|
368
|
+
os.makedirs(path, exist_ok=True)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _restrict_global_config_permissions():
|
|
372
|
+
try:
|
|
373
|
+
os.chmod(GLOBAL_CONFIG_DIR, 0o700)
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
try:
|
|
377
|
+
os.chmod(GLOBAL_CONFIG_FILE, 0o600)
|
|
378
|
+
except Exception:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def write_text(path, content):
|
|
383
|
+
ensure_dir(os.path.dirname(path) or ".")
|
|
384
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
385
|
+
f.write(content)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def slugify_package_name(name):
|
|
389
|
+
normalized = re.sub(r"[^a-z0-9-_]+", "-", name.strip().lower())
|
|
390
|
+
normalized = re.sub(r"-{2,}", "-", normalized).strip("-")
|
|
391
|
+
return normalized or "tw-site"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def build_package_json(project_name):
|
|
395
|
+
package_name = slugify_package_name(project_name)
|
|
396
|
+
return json.dumps(
|
|
397
|
+
{
|
|
398
|
+
"name": package_name,
|
|
399
|
+
"private": True,
|
|
400
|
+
"version": "0.1.0",
|
|
401
|
+
"engines": {
|
|
402
|
+
"node": ">=18",
|
|
403
|
+
},
|
|
404
|
+
"scripts": {
|
|
405
|
+
"dev": "tw dev",
|
|
406
|
+
"build": "tw build",
|
|
407
|
+
"export": "tw export",
|
|
408
|
+
"preview": "tw preview",
|
|
409
|
+
"clean": "tw clean",
|
|
410
|
+
"doctor": "tw doctor",
|
|
411
|
+
"info": "tw info",
|
|
412
|
+
"deploy": "tw deploy",
|
|
413
|
+
},
|
|
414
|
+
"dependencies": {},
|
|
415
|
+
"devDependencies": {},
|
|
416
|
+
},
|
|
417
|
+
indent=2,
|
|
418
|
+
) + "\n"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def build_vercel_json():
|
|
422
|
+
return json.dumps(
|
|
423
|
+
{
|
|
424
|
+
"buildCommand": "tw build",
|
|
425
|
+
"outputDirectory": "dist",
|
|
426
|
+
},
|
|
427
|
+
indent=2,
|
|
428
|
+
) + "\n"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def load_global_config():
|
|
432
|
+
if not os.path.exists(GLOBAL_CONFIG_FILE):
|
|
433
|
+
return {}
|
|
434
|
+
try:
|
|
435
|
+
with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
436
|
+
data = json.load(f)
|
|
437
|
+
return data if isinstance(data, dict) else {}
|
|
438
|
+
except Exception as err:
|
|
439
|
+
# Don't silently wipe config if the file is corrupt.
|
|
440
|
+
log(f"⚠️ Failed to read global config (ignoring): {GLOBAL_CONFIG_FILE} ({err})", level="warning")
|
|
441
|
+
return {}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def save_global_config(config):
|
|
445
|
+
ensure_dir(GLOBAL_CONFIG_DIR)
|
|
446
|
+
_restrict_global_config_permissions()
|
|
447
|
+
# Atomic write (temp-file + rename) to avoid corrupting config on crash/kill.
|
|
448
|
+
tmp_path = GLOBAL_CONFIG_FILE + ".tmp"
|
|
449
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
450
|
+
json.dump(config, f, indent=2, sort_keys=True)
|
|
451
|
+
os.replace(tmp_path, GLOBAL_CONFIG_FILE)
|
|
452
|
+
_restrict_global_config_permissions()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def find_project_root(start_dir=None):
|
|
456
|
+
current = os.path.abspath(start_dir or os.getcwd())
|
|
457
|
+
while True:
|
|
458
|
+
config_path = os.path.join(current, "tw.config")
|
|
459
|
+
home_dir = os.path.join(current, "[home]")
|
|
460
|
+
if os.path.exists(config_path) and os.path.isdir(home_dir):
|
|
461
|
+
return current
|
|
462
|
+
parent = os.path.dirname(current)
|
|
463
|
+
if parent == current:
|
|
464
|
+
break
|
|
465
|
+
current = parent
|
|
466
|
+
raise RuntimeError(
|
|
467
|
+
"TW project root not found. Run `tw create <name>` or `cd` into a TW project folder."
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def create_project(project_name, parent_dir=None):
|
|
472
|
+
parent_dir = os.path.abspath(parent_dir or os.getcwd())
|
|
473
|
+
root = os.path.join(parent_dir, project_name)
|
|
474
|
+
if os.path.exists(root) and os.listdir(root):
|
|
475
|
+
raise RuntimeError(f"Target folder already exists and is not empty: {root}")
|
|
476
|
+
|
|
477
|
+
ensure_dir(root)
|
|
478
|
+
for rel_path, content in STARTER_FILES.items():
|
|
479
|
+
write_text(os.path.join(root, rel_path), content)
|
|
480
|
+
|
|
481
|
+
write_text(os.path.join(root, "package.json"), build_package_json(project_name))
|
|
482
|
+
write_text(os.path.join(root, "vercel.json"), build_vercel_json())
|
|
483
|
+
|
|
484
|
+
for extra_dir in [
|
|
485
|
+
os.path.join(root, ".tw", "cache"),
|
|
486
|
+
os.path.join(root, ".tw", "manifest"),
|
|
487
|
+
os.path.join(root, ".tw", "compiler", "chunks"),
|
|
488
|
+
os.path.join(root, "app"),
|
|
489
|
+
os.path.join(root, "pages"),
|
|
490
|
+
os.path.join(root, "components"),
|
|
491
|
+
os.path.join(root, "layouts"),
|
|
492
|
+
os.path.join(root, "api"),
|
|
493
|
+
os.path.join(root, "middleware"),
|
|
494
|
+
os.path.join(root, "dist"),
|
|
495
|
+
os.path.join(root, "public"),
|
|
496
|
+
os.path.join(root, "[home]", "assets", "images"),
|
|
497
|
+
os.path.join(root, "[home]", "assets", "js"),
|
|
498
|
+
os.path.join(root, "[home]", "assets", "css"),
|
|
499
|
+
os.path.join(root, "[home]", "assets", "fonts"),
|
|
500
|
+
os.path.join(root, "[home]", "api"),
|
|
501
|
+
os.path.join(root, "[home]", "hooks"),
|
|
502
|
+
os.path.join(root, "[home]", "stores"),
|
|
503
|
+
os.path.join(root, "[home]", "middleware"),
|
|
504
|
+
os.path.join(root, "[home]", "plugins"),
|
|
505
|
+
os.path.join(root, "[home]", "types"),
|
|
506
|
+
]:
|
|
507
|
+
ensure_dir(extra_dir)
|
|
508
|
+
|
|
509
|
+
log(f"✔ Project created: {root}")
|
|
510
|
+
log("Next steps:")
|
|
511
|
+
log(f" cd {project_name}")
|
|
512
|
+
log(f" {CLI_NAME} dev")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def open_browser_later(url):
|
|
516
|
+
timer = threading.Timer(1.0, lambda: webbrowser.open(url))
|
|
517
|
+
timer.daemon = True
|
|
518
|
+
timer.start()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def resolve_output_dir(project_root):
|
|
522
|
+
return os.path.join(project_root, "dist")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def configure_project_for_file(file_path):
|
|
526
|
+
abs_path = os.path.abspath(file_path)
|
|
527
|
+
try:
|
|
528
|
+
project_root = find_project_root(os.path.dirname(abs_path))
|
|
529
|
+
except Exception:
|
|
530
|
+
return None
|
|
531
|
+
framework.configure_compiler_paths(project_root)
|
|
532
|
+
return project_root
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _guess_route_for_file(abs_path):
|
|
536
|
+
for page_info in compiler.discover_pages():
|
|
537
|
+
if os.path.abspath(page_info["path"]) == abs_path:
|
|
538
|
+
return compiler.route_path_from_page_info(page_info)
|
|
539
|
+
return "/"
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _diagnostics_have_errors(items):
|
|
543
|
+
return any(item.get("severity") == "error" for item in items or [])
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _serialize_token(token):
|
|
547
|
+
if hasattr(token, "to_dict"):
|
|
548
|
+
return token.to_dict()
|
|
549
|
+
return {
|
|
550
|
+
"type": getattr(token, "type", ""),
|
|
551
|
+
"value": getattr(token, "value", ""),
|
|
552
|
+
"line": getattr(token, "line", 0),
|
|
553
|
+
"col": getattr(token, "col", 0),
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _json_safe(value):
|
|
558
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
559
|
+
return value
|
|
560
|
+
if isinstance(value, dict):
|
|
561
|
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
562
|
+
if isinstance(value, (list, tuple, set)):
|
|
563
|
+
return [_json_safe(item) for item in value]
|
|
564
|
+
if hasattr(value, "to_dict") and callable(value.to_dict):
|
|
565
|
+
try:
|
|
566
|
+
return _json_safe(value.to_dict())
|
|
567
|
+
except Exception:
|
|
568
|
+
pass
|
|
569
|
+
if hasattr(value, "__dict__"):
|
|
570
|
+
try:
|
|
571
|
+
return _json_safe(
|
|
572
|
+
{
|
|
573
|
+
key: item
|
|
574
|
+
for key, item in vars(value).items()
|
|
575
|
+
if not str(key).startswith("_")
|
|
576
|
+
}
|
|
577
|
+
)
|
|
578
|
+
except Exception:
|
|
579
|
+
pass
|
|
580
|
+
return repr(value)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _diagnostic_summary(items):
|
|
584
|
+
summary = {"total": 0, "errors": 0, "warnings": 0, "info": 0, "by_phase": {}, "by_code": {}}
|
|
585
|
+
for item in items or []:
|
|
586
|
+
summary["total"] += 1
|
|
587
|
+
severity = str(item.get("severity", "info") or "info").lower()
|
|
588
|
+
if severity == "error":
|
|
589
|
+
summary["errors"] += 1
|
|
590
|
+
elif severity == "warning":
|
|
591
|
+
summary["warnings"] += 1
|
|
592
|
+
else:
|
|
593
|
+
summary["info"] += 1
|
|
594
|
+
phase = item.get("phase") or "unspecified"
|
|
595
|
+
summary["by_phase"][phase] = summary["by_phase"].get(phase, 0) + 1
|
|
596
|
+
code = item.get("code") or "unknown"
|
|
597
|
+
summary["by_code"][code] = summary["by_code"].get(code, 0) + 1
|
|
598
|
+
return summary
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _compile_file_artifacts(file_path, *, include_css=False, capture_errors=False):
|
|
602
|
+
abs_path = os.path.abspath(file_path)
|
|
603
|
+
configure_project_for_file(abs_path)
|
|
604
|
+
css_href = None
|
|
605
|
+
if include_css and os.path.exists(getattr(compiler, "STYLE_FILE", "")):
|
|
606
|
+
css_href, _ = compiler.read_global_stylesheet()
|
|
607
|
+
return compiler.compile_file_pipeline(
|
|
608
|
+
abs_path,
|
|
609
|
+
css_href=css_href,
|
|
610
|
+
route_path=_guess_route_for_file(abs_path),
|
|
611
|
+
capture_errors=capture_errors,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def command_create(args):
|
|
616
|
+
create_project(args.name, args.directory)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def command_init(args):
|
|
620
|
+
"""Create a new TW project in the current directory (zero‑config)."""
|
|
621
|
+
project_name = args.name or os.path.basename(os.getcwd())
|
|
622
|
+
create_project(project_name, os.getcwd())
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def command_doctor(args):
|
|
626
|
+
"""Run project health checks and deployment compatibility."""
|
|
627
|
+
project_root = find_project_root(args.project_root)
|
|
628
|
+
checks = framework.doctor_project(project_root)
|
|
629
|
+
# Extra CLI-level checks (global deploy config)
|
|
630
|
+
config = load_global_config()
|
|
631
|
+
if os.path.exists(GLOBAL_CONFIG_FILE):
|
|
632
|
+
checks.append({"name": "Global deploy config", "ok": True, "detail": GLOBAL_CONFIG_FILE})
|
|
633
|
+
else:
|
|
634
|
+
checks.append({"name": "Global deploy config", "ok": False, "detail": f"Missing: {GLOBAL_CONFIG_FILE} (run `tw login` to create it)"})
|
|
635
|
+
# Provider token hints (best-effort)
|
|
636
|
+
if config.get("vercel_token") or os.environ.get("VERCEL_TOKEN"):
|
|
637
|
+
checks.append({"name": "Vercel token", "ok": True, "detail": "Token available"})
|
|
638
|
+
else:
|
|
639
|
+
checks.append({"name": "Vercel token", "ok": False, "detail": "Missing Vercel token (run `tw login --vercel-token ...` or set VERCEL_TOKEN)"})
|
|
640
|
+
failed = 0
|
|
641
|
+
for check in checks:
|
|
642
|
+
status = "OK" if check["ok"] else "WARN"
|
|
643
|
+
log(f"[{status}] {check['name']}: {check['detail']}", level="warning" if status == "WARN" else "info")
|
|
644
|
+
if not check["ok"] and check["name"] in {"tw.config", "[home]", "Route discovery"}:
|
|
645
|
+
failed += 1
|
|
646
|
+
return 1 if failed else 0
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def command_dev(args):
|
|
650
|
+
project_root = find_project_root(args.project_root)
|
|
651
|
+
host = args.host
|
|
652
|
+
port = args.port
|
|
653
|
+
url = f"http://{host}:{port}"
|
|
654
|
+
if not args.no_open:
|
|
655
|
+
open_browser_later(url)
|
|
656
|
+
try:
|
|
657
|
+
framework.run_dev_server(project_root, host, port)
|
|
658
|
+
except Exception as err:
|
|
659
|
+
log(f"✖ Dev server start failed: {err}", level="error")
|
|
660
|
+
return 1
|
|
661
|
+
return 0
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def command_build(args):
|
|
665
|
+
project_root = find_project_root(args.project_root)
|
|
666
|
+
output_dir = args.out_dir or resolve_output_dir(project_root)
|
|
667
|
+
if args.clean:
|
|
668
|
+
framework.clean_project_outputs(project_root)
|
|
669
|
+
strict = bool(getattr(args, "strict", False))
|
|
670
|
+
fail_on_warnings = bool(
|
|
671
|
+
strict or getattr(args, "fail_on_warnings", False) or getattr(args, "prod", False)
|
|
672
|
+
)
|
|
673
|
+
debug = getattr(args, "debug", False)
|
|
674
|
+
|
|
675
|
+
adapters = [args.adapter] if args.adapter else None
|
|
676
|
+
|
|
677
|
+
def run_once(force_build=False):
|
|
678
|
+
summary = framework.build_hidden_site(
|
|
679
|
+
project_root=project_root,
|
|
680
|
+
output_dir=output_dir,
|
|
681
|
+
force=args.force or force_build,
|
|
682
|
+
workers=args.workers,
|
|
683
|
+
minify=(args.prod or not args.dev) and not args.no_minify,
|
|
684
|
+
strict=strict,
|
|
685
|
+
adapters=adapters,
|
|
686
|
+
debug=debug,
|
|
687
|
+
)
|
|
688
|
+
if summary.errors:
|
|
689
|
+
log(f"✖ Build finished with {summary.errors} error(s)", level="error")
|
|
690
|
+
return summary, 1
|
|
691
|
+
if getattr(summary, "warnings", 0):
|
|
692
|
+
log(f"⚠️ Build warnings: {summary.warnings}", level="warning")
|
|
693
|
+
if fail_on_warnings:
|
|
694
|
+
if getattr(args, "prod", False) and not getattr(args, "fail_on_warnings", False):
|
|
695
|
+
log("✖ Failing build because --prod implies --fail-on-warnings", level="error")
|
|
696
|
+
else:
|
|
697
|
+
log("✖ Failing build because --fail-on-warnings was set", level="error")
|
|
698
|
+
return summary, 1
|
|
699
|
+
log("✔ Build completed")
|
|
700
|
+
log(f"✔ Optimized {summary.built} page(s)")
|
|
701
|
+
if summary.skipped:
|
|
702
|
+
log(f"✔ Reused cache for {summary.skipped} page(s)")
|
|
703
|
+
if args.analyze:
|
|
704
|
+
route_manifest = os.path.join(output_dir, "_tw", "route-manifest.json")
|
|
705
|
+
api_manifest = os.path.join(output_dir, "_tw", "api-manifest.json")
|
|
706
|
+
log(f"✔ Route analysis: {route_manifest}")
|
|
707
|
+
log(f"✔ API analysis: {api_manifest}")
|
|
708
|
+
log("✔ Ready for deployment")
|
|
709
|
+
return summary, 0
|
|
710
|
+
|
|
711
|
+
_, code = run_once()
|
|
712
|
+
if code or not args.watch:
|
|
713
|
+
# Generate deployment metadata even on failure (for debugging)
|
|
714
|
+
try:
|
|
715
|
+
framework.generate_deploy_metadata(
|
|
716
|
+
output_dir=args.out_dir or resolve_output_dir(project_root),
|
|
717
|
+
config=compiler.load_config(),
|
|
718
|
+
)
|
|
719
|
+
except Exception:
|
|
720
|
+
pass
|
|
721
|
+
return code
|
|
722
|
+
|
|
723
|
+
watcher_project = framework.TWProject(project_root)
|
|
724
|
+
watched = watcher_project.list_source_files()
|
|
725
|
+
last_stats = {}
|
|
726
|
+
for p in watched:
|
|
727
|
+
try:
|
|
728
|
+
st = os.stat(p)
|
|
729
|
+
last_stats[p] = (st.st_mtime_ns, st.st_size)
|
|
730
|
+
except Exception:
|
|
731
|
+
last_stats[p] = None
|
|
732
|
+
log("👀 Build watch mode active")
|
|
733
|
+
try:
|
|
734
|
+
while True:
|
|
735
|
+
time.sleep(1)
|
|
736
|
+
changed = False
|
|
737
|
+
for p in list(watched):
|
|
738
|
+
try:
|
|
739
|
+
st = os.stat(p)
|
|
740
|
+
sig = (st.st_mtime_ns, st.st_size)
|
|
741
|
+
except Exception:
|
|
742
|
+
sig = None
|
|
743
|
+
if last_stats.get(p) != sig:
|
|
744
|
+
changed = True
|
|
745
|
+
break
|
|
746
|
+
if not changed:
|
|
747
|
+
new_list = watcher_project.list_source_files()
|
|
748
|
+
if set(new_list) != set(watched):
|
|
749
|
+
changed = True
|
|
750
|
+
|
|
751
|
+
if changed:
|
|
752
|
+
watched = watcher_project.list_source_files()
|
|
753
|
+
last_stats = {}
|
|
754
|
+
for p in watched:
|
|
755
|
+
try:
|
|
756
|
+
st = os.stat(p)
|
|
757
|
+
last_stats[p] = (st.st_mtime_ns, st.st_size)
|
|
758
|
+
except Exception:
|
|
759
|
+
last_stats[p] = None
|
|
760
|
+
watcher_project.invalidate()
|
|
761
|
+
log("↻ Change detected, rebuilding...")
|
|
762
|
+
_, code = run_once(force_build=True)
|
|
763
|
+
if code:
|
|
764
|
+
log("⚠️ Watching will continue. Fix the error(s) and save the file to retry.", level="warning")
|
|
765
|
+
except KeyboardInterrupt:
|
|
766
|
+
log("\nWatch mode stopped")
|
|
767
|
+
return 0
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def command_export(args):
|
|
771
|
+
project_root = find_project_root(args.project_root)
|
|
772
|
+
summary = framework.build_hidden_site(
|
|
773
|
+
project_root=project_root,
|
|
774
|
+
output_dir=args.out_dir or resolve_output_dir(project_root),
|
|
775
|
+
force=True,
|
|
776
|
+
workers=args.workers,
|
|
777
|
+
minify=not args.no_minify,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
if summary.errors:
|
|
781
|
+
log(f"✖ Export finished with {summary.errors} error(s)", level="error")
|
|
782
|
+
return 1
|
|
783
|
+
if getattr(summary, "warnings", 0):
|
|
784
|
+
log(f"⚠️ Export warnings: {summary.warnings}", level="warning")
|
|
785
|
+
if args.fail_on_warnings:
|
|
786
|
+
log("✖ Failing export because --fail-on-warnings was set", level="error")
|
|
787
|
+
return 1
|
|
788
|
+
|
|
789
|
+
log("✔ Static export completed")
|
|
790
|
+
log(f"✔ Output ready in {summary.output_dir}")
|
|
791
|
+
return 0
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def command_preview(args):
|
|
795
|
+
project_root = find_project_root(args.project_root)
|
|
796
|
+
output_dir = args.out_dir or resolve_output_dir(project_root)
|
|
797
|
+
if not args.no_build:
|
|
798
|
+
result = command_export(
|
|
799
|
+
argparse.Namespace(
|
|
800
|
+
project_root=project_root,
|
|
801
|
+
out_dir=output_dir,
|
|
802
|
+
workers=args.workers,
|
|
803
|
+
no_minify=args.no_minify,
|
|
804
|
+
)
|
|
805
|
+
)
|
|
806
|
+
if result:
|
|
807
|
+
return result
|
|
808
|
+
|
|
809
|
+
url = f"http://{args.host}:{args.port}"
|
|
810
|
+
if not args.no_open:
|
|
811
|
+
open_browser_later(url)
|
|
812
|
+
framework.run_preview_server(output_dir=output_dir, host=args.host, port=args.port)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def command_clean(args):
|
|
816
|
+
project_root = find_project_root(args.project_root)
|
|
817
|
+
framework.clean_project_outputs(project_root)
|
|
818
|
+
log("✔ dist/ aur .tw/ clean kar diye gaye")
|
|
819
|
+
return 0
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def command_doctor(args):
|
|
823
|
+
project_root = find_project_root(args.project_root)
|
|
824
|
+
checks = framework.doctor_project(project_root)
|
|
825
|
+
# Extra CLI-level checks (global deploy config)
|
|
826
|
+
config = load_global_config()
|
|
827
|
+
if os.path.exists(GLOBAL_CONFIG_FILE):
|
|
828
|
+
checks.append({"name": "Global deploy config", "ok": True, "detail": GLOBAL_CONFIG_FILE})
|
|
829
|
+
else:
|
|
830
|
+
checks.append({"name": "Global deploy config", "ok": False, "detail": f"Missing: {GLOBAL_CONFIG_FILE} (run `tw login` to create it)"})
|
|
831
|
+
# Provider token hints (best-effort)
|
|
832
|
+
if config.get("vercel_token") or os.environ.get("VERCEL_TOKEN"):
|
|
833
|
+
checks.append({"name": "Vercel token", "ok": True, "detail": "Token available"})
|
|
834
|
+
else:
|
|
835
|
+
checks.append({"name": "Vercel token", "ok": False, "detail": "Missing Vercel token (run `tw login --vercel-token ...` or set VERCEL_TOKEN)"})
|
|
836
|
+
failed = 0
|
|
837
|
+
for check in checks:
|
|
838
|
+
status = "OK" if check["ok"] else "WARN"
|
|
839
|
+
log(f"[{status}] {check['name']}: {check['detail']}", level="warning" if status == "WARN" else "info")
|
|
840
|
+
if not check["ok"] and check["name"] in {"tw.config", "[home]", "Route discovery"}:
|
|
841
|
+
failed += 1
|
|
842
|
+
return 1 if failed else 0
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def command_info(args):
|
|
846
|
+
project_root = find_project_root(args.project_root)
|
|
847
|
+
info = framework.inspect_project(project_root)
|
|
848
|
+
print(f"Project root: {info['project_root']}")
|
|
849
|
+
print(f"Source root: {info['source_root']}")
|
|
850
|
+
print(f"Output dir: {info['output_dir']}")
|
|
851
|
+
print(f"Hidden dir: {info['hidden_dir']}")
|
|
852
|
+
print(f"Pages: {info['page_count']}")
|
|
853
|
+
print(f"Static routes: {info['static_routes']}")
|
|
854
|
+
print(f"Dynamic routes: {info['dynamic_routes']}")
|
|
855
|
+
print(f"Components: {info['component_count']}")
|
|
856
|
+
print(f"Custom 404: {'yes' if info['has_404'] else 'no'}")
|
|
857
|
+
print(f"Custom 500: {'yes' if info['has_500'] else 'no'}")
|
|
858
|
+
print(f"Modular pipeline: {'yes' if info['modular_pipeline'] else 'no'}")
|
|
859
|
+
return 0
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _write_or_print_output(output_path, payload):
|
|
863
|
+
if output_path:
|
|
864
|
+
write_text(output_path, payload)
|
|
865
|
+
log(f"✔ Output saved: {output_path}")
|
|
866
|
+
return
|
|
867
|
+
print(payload)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def command_ast(args):
|
|
871
|
+
configure_project_for_file(args.file)
|
|
872
|
+
program = parse_file(os.path.abspath(args.file))
|
|
873
|
+
diagnostics = analyze_program(program)
|
|
874
|
+
payload = {
|
|
875
|
+
"ast": program.to_dict(),
|
|
876
|
+
}
|
|
877
|
+
if args.diagnostics:
|
|
878
|
+
payload["diagnostics"] = diagnostics.to_list()
|
|
879
|
+
payload["summary"] = _diagnostic_summary(payload["diagnostics"])
|
|
880
|
+
_write_or_print_output(args.out, json.dumps(_json_safe(payload), indent=2, ensure_ascii=False))
|
|
881
|
+
return 1 if diagnostics.has_errors else 0
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def command_ir(args):
|
|
885
|
+
configure_project_for_file(args.file)
|
|
886
|
+
program = parse_file(os.path.abspath(args.file))
|
|
887
|
+
diagnostics = analyze_program(program)
|
|
888
|
+
ir_program = lower_program(program)
|
|
889
|
+
payload = {
|
|
890
|
+
"ir": ir_program.to_dict(),
|
|
891
|
+
}
|
|
892
|
+
if args.diagnostics:
|
|
893
|
+
payload["diagnostics"] = diagnostics.to_list()
|
|
894
|
+
payload["summary"] = _diagnostic_summary(payload["diagnostics"])
|
|
895
|
+
_write_or_print_output(args.out, json.dumps(_json_safe(payload), indent=2, ensure_ascii=False))
|
|
896
|
+
return 1 if diagnostics.has_errors else 0
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def command_run_file(args):
|
|
900
|
+
artifacts = _compile_file_artifacts(args.file, include_css=True, capture_errors=args.diagnostics)
|
|
901
|
+
if args.diagnostics:
|
|
902
|
+
payload = {
|
|
903
|
+
"html": artifacts.html or "",
|
|
904
|
+
"diagnostics": artifacts.diagnostics,
|
|
905
|
+
"metadata": artifacts.metadata,
|
|
906
|
+
"dependencies": artifacts.dependencies,
|
|
907
|
+
"summary": _diagnostic_summary(artifacts.diagnostics),
|
|
908
|
+
}
|
|
909
|
+
_write_or_print_output(args.out, json.dumps(_json_safe(payload), indent=2, ensure_ascii=False))
|
|
910
|
+
else:
|
|
911
|
+
_write_or_print_output(args.out, artifacts.html or "")
|
|
912
|
+
return 1 if _diagnostics_have_errors(artifacts.diagnostics) else 0
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def command_tokens(args):
|
|
916
|
+
configure_project_for_file(args.file)
|
|
917
|
+
payload = {
|
|
918
|
+
"tokens": [_serialize_token(token) for token in tokenize_file(os.path.abspath(args.file))]
|
|
919
|
+
}
|
|
920
|
+
_write_or_print_output(args.out, json.dumps(_json_safe(payload), indent=2, ensure_ascii=False))
|
|
921
|
+
return 0
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def command_check(args):
|
|
925
|
+
artifacts = _compile_file_artifacts(args.file, capture_errors=True)
|
|
926
|
+
payload = {
|
|
927
|
+
"diagnostics": artifacts.diagnostics,
|
|
928
|
+
"metadata": artifacts.metadata,
|
|
929
|
+
"dependencies": artifacts.dependencies,
|
|
930
|
+
"route_path": artifacts.route_path,
|
|
931
|
+
"pipeline": artifacts.pipeline,
|
|
932
|
+
"summary": _diagnostic_summary(artifacts.diagnostics),
|
|
933
|
+
}
|
|
934
|
+
if args.include_ast:
|
|
935
|
+
payload["ast"] = artifacts.ast
|
|
936
|
+
if args.include_ir:
|
|
937
|
+
payload["ir"] = artifacts.ir
|
|
938
|
+
_write_or_print_output(args.out, json.dumps(_json_safe(payload), indent=2, ensure_ascii=False))
|
|
939
|
+
return 1 if _diagnostics_have_errors(artifacts.diagnostics) else 0
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def command_verify(args):
|
|
943
|
+
project_root = find_project_root(args.project_root)
|
|
944
|
+
output_dir = args.out_dir or resolve_output_dir(project_root)
|
|
945
|
+
manifest_path = os.path.join(output_dir, "_tw", "route-manifest.json")
|
|
946
|
+
if not os.path.exists(manifest_path):
|
|
947
|
+
log(f" Route manifest not found: {manifest_path}. Run `tw build` first.", level="error")
|
|
948
|
+
return 1
|
|
949
|
+
|
|
950
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
951
|
+
manifest = json.load(f)
|
|
952
|
+
|
|
953
|
+
routes = manifest.get("routes") if isinstance(manifest, dict) else manifest
|
|
954
|
+
if not isinstance(routes, list):
|
|
955
|
+
log(" Unexpected route-manifest.json format; cannot verify.", level="error")
|
|
956
|
+
return 1
|
|
957
|
+
|
|
958
|
+
from .signature import compute_tw_signature
|
|
959
|
+
|
|
960
|
+
total = 0
|
|
961
|
+
ok = 0
|
|
962
|
+
mismatched = []
|
|
963
|
+
for route in routes:
|
|
964
|
+
source_path = route.get("source") or route.get("source_path") or route.get("file")
|
|
965
|
+
output_path = route.get("output") or route.get("output_path") or route.get("html_path")
|
|
966
|
+
route_name = route.get("route") or route.get("path") or source_path or "?"
|
|
967
|
+
if not source_path or not output_path:
|
|
968
|
+
continue
|
|
969
|
+
abs_source = os.path.join(project_root, source_path) if not os.path.isabs(source_path) else source_path
|
|
970
|
+
abs_output = os.path.join(output_dir, output_path) if not os.path.isabs(output_path) else output_path
|
|
971
|
+
if not os.path.exists(abs_source) or not os.path.exists(abs_output):
|
|
972
|
+
continue
|
|
973
|
+
|
|
974
|
+
total += 1
|
|
975
|
+
try:
|
|
976
|
+
program = parse_file(abs_source)
|
|
977
|
+
expected_sig = compute_tw_signature(program)
|
|
978
|
+
except Exception as err:
|
|
979
|
+
mismatched.append((route_name, f"failed to recompile source: {err}"))
|
|
980
|
+
continue
|
|
981
|
+
|
|
982
|
+
with open(abs_output, "r", encoding="utf-8") as f:
|
|
983
|
+
built_html = f.read()
|
|
984
|
+
|
|
985
|
+
match = re.search(r'name="tw-signature"\s+content="([a-f0-9]+)"', built_html)
|
|
986
|
+
found_sig = match.group(1) if match else None
|
|
987
|
+
|
|
988
|
+
if found_sig == expected_sig:
|
|
989
|
+
ok += 1
|
|
990
|
+
else:
|
|
991
|
+
mismatched.append((route_name, f"expected {expected_sig}, found {found_sig or 'none'}"))
|
|
992
|
+
|
|
993
|
+
log(f" Verified {ok}/{total} route(s) against their TW source.")
|
|
994
|
+
if mismatched:
|
|
995
|
+
log(f" {len(mismatched)} mismatch(es):", level="warning")
|
|
996
|
+
for name, detail in mismatched:
|
|
997
|
+
log(f" - {name}: {detail}", level="warning")
|
|
998
|
+
return 1
|
|
999
|
+
return 0
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def apply_deploy_config(config, provider):
|
|
1003
|
+
"""Returns a cleanup callable to restore env changes."""
|
|
1004
|
+
cleanup = lambda: None
|
|
1005
|
+
if provider == "vercel" and config.get("vercel_token"):
|
|
1006
|
+
old_value = os.environ.get("VERCEL_TOKEN")
|
|
1007
|
+
os.environ["VERCEL_TOKEN"] = config["vercel_token"]
|
|
1008
|
+
|
|
1009
|
+
def cleanup():
|
|
1010
|
+
if old_value is None:
|
|
1011
|
+
os.environ.pop("VERCEL_TOKEN", None)
|
|
1012
|
+
else:
|
|
1013
|
+
os.environ["VERCEL_TOKEN"] = old_value
|
|
1014
|
+
|
|
1015
|
+
return cleanup
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def resolve_provider(args, config):
|
|
1019
|
+
if args.vercel:
|
|
1020
|
+
return "vercel"
|
|
1021
|
+
if args.cloudflare:
|
|
1022
|
+
return "cloudflare"
|
|
1023
|
+
if args.provider:
|
|
1024
|
+
return args.provider
|
|
1025
|
+
return config.get("default_provider", "local")
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def command_login(args):
|
|
1029
|
+
config = load_global_config()
|
|
1030
|
+
if args.provider:
|
|
1031
|
+
config["default_provider"] = args.provider
|
|
1032
|
+
if args.vercel_token:
|
|
1033
|
+
config["vercel_token"] = args.vercel_token
|
|
1034
|
+
save_global_config(config)
|
|
1035
|
+
log("✔ TW deploy config saved")
|
|
1036
|
+
if config.get("default_provider"):
|
|
1037
|
+
log(f"✔ Default provider: {config['default_provider']}")
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def command_deploy(args):
|
|
1041
|
+
project_root = find_project_root(args.project_root)
|
|
1042
|
+
config = load_global_config()
|
|
1043
|
+
provider = resolve_provider(args, config)
|
|
1044
|
+
cleanup = apply_deploy_config(config, provider)
|
|
1045
|
+
try:
|
|
1046
|
+
if provider == "vercel" and not (os.environ.get("VERCEL_TOKEN") or config.get("vercel_token")):
|
|
1047
|
+
log("✖ Missing Vercel token. Set `VERCEL_TOKEN` or run `tw login --vercel-token <token>`.", level="error")
|
|
1048
|
+
return 1
|
|
1049
|
+
framework.run_deploy(
|
|
1050
|
+
project_root=project_root,
|
|
1051
|
+
output_dir=args.out_dir or resolve_output_dir(project_root),
|
|
1052
|
+
provider=provider,
|
|
1053
|
+
production=args.prod or provider in {"vercel", "cloudflare", "netlify", "docker"},
|
|
1054
|
+
dry_run=bool(getattr(args, "dry_run", False)),
|
|
1055
|
+
)
|
|
1056
|
+
finally:
|
|
1057
|
+
cleanup()
|
|
1058
|
+
if getattr(args, "dry_run", False):
|
|
1059
|
+
log("✔ Deploy dry-run completed")
|
|
1060
|
+
else:
|
|
1061
|
+
log("✔ Deploy completed")
|
|
1062
|
+
return 0
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def command_serve(args):
|
|
1066
|
+
"""Start the TW production server."""
|
|
1067
|
+
from .server import run_production_server
|
|
1068
|
+
project_root = find_project_root(args.project_root)
|
|
1069
|
+
output_dir = args.out_dir or resolve_output_dir(project_root)
|
|
1070
|
+
host = args.host or os.environ.get("TW_HOST", "0.0.0.0")
|
|
1071
|
+
port = args.port or int(os.environ.get("TW_PORT", "8000"))
|
|
1072
|
+
|
|
1073
|
+
# Optionally build first
|
|
1074
|
+
if not args.no_build:
|
|
1075
|
+
log("Building before serve...")
|
|
1076
|
+
summary = framework.build_hidden_site(
|
|
1077
|
+
project_root=project_root,
|
|
1078
|
+
output_dir=output_dir,
|
|
1079
|
+
force=False,
|
|
1080
|
+
minify=not args.no_minify,
|
|
1081
|
+
)
|
|
1082
|
+
if summary.errors:
|
|
1083
|
+
log(f"✖ Build had {summary.errors} error(s). Serving anyway...", level="error")
|
|
1084
|
+
if getattr(summary, "warnings", 0):
|
|
1085
|
+
log(f"⚠️ Build warnings: {summary.warnings}", level="warning")
|
|
1086
|
+
if args.fail_on_warnings:
|
|
1087
|
+
log("✖ Not starting server because --fail-on-warnings was set", level="error")
|
|
1088
|
+
return 1
|
|
1089
|
+
|
|
1090
|
+
try:
|
|
1091
|
+
run_production_server(
|
|
1092
|
+
project_root=project_root,
|
|
1093
|
+
host=host,
|
|
1094
|
+
port=port,
|
|
1095
|
+
output_dir=output_dir if os.path.isdir(output_dir) else None,
|
|
1096
|
+
)
|
|
1097
|
+
except Exception as err:
|
|
1098
|
+
log(f"✖ Server error: {err}", level="error")
|
|
1099
|
+
return 1
|
|
1100
|
+
return 0
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def build_parser():
|
|
1104
|
+
parser = argparse.ArgumentParser(description="TW framework CLI")
|
|
1105
|
+
parser.add_argument("--project-root", help="Manual project root override")
|
|
1106
|
+
parser.add_argument("--debug", action="store_true", help="Print full tracebacks and internal state")
|
|
1107
|
+
|
|
1108
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1109
|
+
|
|
1110
|
+
def add_output_dir_arg(subparser, help_text):
|
|
1111
|
+
subparser.add_argument("--out-dir", help=help_text)
|
|
1112
|
+
|
|
1113
|
+
def add_workers_arg(subparser):
|
|
1114
|
+
subparser.add_argument("--workers", type=int, default=framework.compiler.DEFAULT_WORKERS)
|
|
1115
|
+
|
|
1116
|
+
def add_host_port_args(subparser, *, host_default, port_default, allow_no_open=False):
|
|
1117
|
+
subparser.add_argument("--host", default=host_default)
|
|
1118
|
+
subparser.add_argument("--port", type=int, default=port_default)
|
|
1119
|
+
if allow_no_open:
|
|
1120
|
+
subparser.add_argument("--no-open", action="store_true", help="Disable auto-opening the browser")
|
|
1121
|
+
|
|
1122
|
+
def add_no_minify_arg(subparser, help_text):
|
|
1123
|
+
subparser.add_argument("--no-minify", action="store_true", help=help_text)
|
|
1124
|
+
|
|
1125
|
+
create_parser = subparsers.add_parser("create", help="Create a new TW project")
|
|
1126
|
+
create_parser.add_argument("name", help="Project folder name")
|
|
1127
|
+
create_parser.add_argument("--directory", help="Parent directory where the project will be created")
|
|
1128
|
+
create_parser.set_defaults(func=command_create)
|
|
1129
|
+
|
|
1130
|
+
init_parser = subparsers.add_parser("init", help="Create a new TW project in the current directory")
|
|
1131
|
+
init_parser.add_argument("name", nargs="?", help="Project folder name (default: current directory name)")
|
|
1132
|
+
init_parser.set_defaults(func=command_init)
|
|
1133
|
+
|
|
1134
|
+
doctor_parser = subparsers.add_parser("doctor", help="Run project health checks and deployment compatibility")
|
|
1135
|
+
doctor_parser.set_defaults(func=command_doctor)
|
|
1136
|
+
|
|
1137
|
+
dev_parser = subparsers.add_parser("dev", help="Run the local dev server")
|
|
1138
|
+
add_host_port_args(
|
|
1139
|
+
dev_parser,
|
|
1140
|
+
host_default=framework.DEFAULT_DEV_HOST,
|
|
1141
|
+
port_default=framework.DEFAULT_DEV_PORT,
|
|
1142
|
+
allow_no_open=True,
|
|
1143
|
+
)
|
|
1144
|
+
dev_parser.set_defaults(func=command_dev)
|
|
1145
|
+
|
|
1146
|
+
build_parser = subparsers.add_parser("build", help="Generate a production build")
|
|
1147
|
+
add_output_dir_arg(build_parser, "Internal output directory")
|
|
1148
|
+
build_parser.add_argument("--force", action="store_true")
|
|
1149
|
+
add_workers_arg(build_parser)
|
|
1150
|
+
build_parser.add_argument("--dev", action="store_true", help="Development-style non-minified build")
|
|
1151
|
+
build_parser.add_argument("--prod", action="store_true", help="Production optimized build")
|
|
1152
|
+
build_parser.add_argument("--watch", action="store_true", help="Rebuild on file changes")
|
|
1153
|
+
build_parser.add_argument(
|
|
1154
|
+
"--analyze",
|
|
1155
|
+
action="store_true",
|
|
1156
|
+
help="After generating route/API analysis files, print their paths",
|
|
1157
|
+
)
|
|
1158
|
+
build_parser.add_argument("--clean", action="store_true", help="Clean dist and cache before building")
|
|
1159
|
+
add_no_minify_arg(build_parser, "Disable HTML/CSS minification")
|
|
1160
|
+
build_parser.add_argument("--fail-on-warnings", action="store_true", help="Treat warnings as build failures")
|
|
1161
|
+
build_parser.add_argument("--strict", action="store_true", help="Strict build (= --fail-on-warnings + route collisions are errors)")
|
|
1162
|
+
build_parser.add_argument("--adapter", choices=["vercel", "netlify", "cloudflare"], help="Generate adapter-specific config files after build")
|
|
1163
|
+
build_parser.add_argument("--debug", action="store_true", help="Print full tracebacks and internal state")
|
|
1164
|
+
build_parser.add_argument("--report", action="store_true", help="Generate build report after build")
|
|
1165
|
+
build_parser.set_defaults(func=command_build)
|
|
1166
|
+
|
|
1167
|
+
export_parser = subparsers.add_parser("export", help="Generate a static export")
|
|
1168
|
+
add_output_dir_arg(export_parser, "Export output directory")
|
|
1169
|
+
add_workers_arg(export_parser)
|
|
1170
|
+
add_no_minify_arg(export_parser, "Disable HTML/CSS minification")
|
|
1171
|
+
export_parser.add_argument("--fail-on-warnings", action="store_true", help="Treat warnings as export failures")
|
|
1172
|
+
export_parser.set_defaults(func=command_export)
|
|
1173
|
+
|
|
1174
|
+
preview_parser = subparsers.add_parser("preview", help="Preview the built dist output")
|
|
1175
|
+
add_output_dir_arg(preview_parser, "Preview output directory")
|
|
1176
|
+
add_host_port_args(
|
|
1177
|
+
preview_parser,
|
|
1178
|
+
host_default=framework.DEFAULT_DEV_HOST,
|
|
1179
|
+
port_default=framework.DEFAULT_PREVIEW_PORT,
|
|
1180
|
+
allow_no_open=True,
|
|
1181
|
+
)
|
|
1182
|
+
add_workers_arg(preview_parser)
|
|
1183
|
+
preview_parser.add_argument("--no-build", action="store_true", help="Serve the existing dist without rebuilding")
|
|
1184
|
+
add_no_minify_arg(preview_parser, "Disable minification in the preview build")
|
|
1185
|
+
preview_parser.set_defaults(func=command_preview)
|
|
1186
|
+
|
|
1187
|
+
clean_parser = subparsers.add_parser("clean", help="Clean dist and hidden cache folders")
|
|
1188
|
+
clean_parser.set_defaults(func=command_clean)
|
|
1189
|
+
|
|
1190
|
+
info_parser = subparsers.add_parser("info", help="Show a project summary")
|
|
1191
|
+
info_parser.set_defaults(func=command_info)
|
|
1192
|
+
|
|
1193
|
+
ast_parser = subparsers.add_parser("ast", help="Print the AST JSON for a TW source file")
|
|
1194
|
+
ast_parser.add_argument("file", help=".tw file path")
|
|
1195
|
+
ast_parser.add_argument("--out", help="AST JSON file save path")
|
|
1196
|
+
ast_parser.add_argument("--diagnostics", action="store_true", help="Include diagnostics in the output")
|
|
1197
|
+
ast_parser.set_defaults(func=command_ast)
|
|
1198
|
+
|
|
1199
|
+
ir_parser = subparsers.add_parser("ir", help="Print the IR JSON for a TW source file")
|
|
1200
|
+
ir_parser.add_argument("file", help=".tw file path")
|
|
1201
|
+
ir_parser.add_argument("--out", help="IR JSON file save path")
|
|
1202
|
+
ir_parser.add_argument("--diagnostics", action="store_true", help="Include diagnostics in the output")
|
|
1203
|
+
ir_parser.set_defaults(func=command_ir)
|
|
1204
|
+
|
|
1205
|
+
run_parser = subparsers.add_parser("run", help="Interpret a TW file and output HTML")
|
|
1206
|
+
run_parser.add_argument("file", help=".tw file path")
|
|
1207
|
+
run_parser.add_argument("--out", help="Rendered HTML file save path")
|
|
1208
|
+
run_parser.add_argument("--diagnostics", action="store_true", help="Also output a diagnostics payload with the HTML")
|
|
1209
|
+
run_parser.set_defaults(func=command_run_file)
|
|
1210
|
+
|
|
1211
|
+
tokens_parser = subparsers.add_parser("tokens", help="Print the token stream JSON for a TW source file")
|
|
1212
|
+
tokens_parser.add_argument("file", help=".tw file path")
|
|
1213
|
+
tokens_parser.add_argument("--out", help="Tokens JSON file save path")
|
|
1214
|
+
tokens_parser.set_defaults(func=command_tokens)
|
|
1215
|
+
|
|
1216
|
+
check_parser = subparsers.add_parser("check", help="Print diagnostics for a TW file")
|
|
1217
|
+
check_parser.add_argument("file", help=".tw file path")
|
|
1218
|
+
check_parser.add_argument("--out", help="Diagnostics JSON file save path")
|
|
1219
|
+
check_parser.add_argument("--include-ast", action="store_true", help="Include AST in the output")
|
|
1220
|
+
check_parser.add_argument("--include-ir", action="store_true", help="Include IR in the output")
|
|
1221
|
+
check_parser.set_defaults(func=command_check)
|
|
1222
|
+
|
|
1223
|
+
login_parser = subparsers.add_parser("login", help="Save deploy provider configuration")
|
|
1224
|
+
login_parser.add_argument("--provider", choices=["local", "vercel", "cloudflare", "netlify", "github-pages", "docker"])
|
|
1225
|
+
login_parser.add_argument("--vercel-token", help="Save a Vercel token")
|
|
1226
|
+
login_parser.set_defaults(func=command_login)
|
|
1227
|
+
|
|
1228
|
+
deploy_parser = subparsers.add_parser("deploy", help="Deploy the current project")
|
|
1229
|
+
add_output_dir_arg(deploy_parser, "Internal output directory")
|
|
1230
|
+
deploy_parser.add_argument("--provider", choices=["local", "vercel", "cloudflare", "netlify", "github-pages", "docker"])
|
|
1231
|
+
deploy_parser.add_argument("--vercel", action="store_true", help="Use the Vercel provider")
|
|
1232
|
+
deploy_parser.add_argument("--cloudflare", action="store_true", help="Use the Cloudflare provider")
|
|
1233
|
+
deploy_parser.add_argument("--prod", action="store_true", help="Production deploy flag")
|
|
1234
|
+
deploy_parser.add_argument("--dry-run", action="store_true", help="Validate config + build, but do not deploy")
|
|
1235
|
+
deploy_parser.set_defaults(func=command_deploy)
|
|
1236
|
+
|
|
1237
|
+
serve_parser = subparsers.add_parser("serve", help="Run the production server (SSR + API routes)")
|
|
1238
|
+
serve_parser.add_argument("--host", default=None, help="Bind host (default: 0.0.0.0)")
|
|
1239
|
+
serve_parser.add_argument("--port", type=int, default=None, help="Bind port (default: 8000)")
|
|
1240
|
+
add_output_dir_arg(serve_parser, "Static output dir (optional)")
|
|
1241
|
+
serve_parser.add_argument("--no-build", action="store_true", help="Skip the build step and serve directly")
|
|
1242
|
+
add_no_minify_arg(serve_parser, "Disable HTML/CSS minification in the pre-build step")
|
|
1243
|
+
serve_parser.add_argument("--fail-on-warnings", action="store_true", help="Do not start server if build emits warnings")
|
|
1244
|
+
serve_parser.set_defaults(func=command_serve)
|
|
1245
|
+
|
|
1246
|
+
return parser
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def main():
|
|
1250
|
+
parser = build_parser()
|
|
1251
|
+
args = parser.parse_args()
|
|
1252
|
+
result = args.func(args)
|
|
1253
|
+
if isinstance(result, int):
|
|
1254
|
+
raise SystemExit(result)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
if __name__ == "__main__":
|
|
1258
|
+
main()
|