tw-framework 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. tw_framework/__init__.py +13 -0
  2. tw_framework/advanced_diagnostics.py +25 -0
  3. tw_framework/asset_optimizer.py +30 -0
  4. tw_framework/ast_nodes.py +283 -0
  5. tw_framework/build.py +7 -0
  6. tw_framework/build_performance.py +26 -0
  7. tw_framework/build_report.py +84 -0
  8. tw_framework/cli.py +1258 -0
  9. tw_framework/code_splitting.py +76 -0
  10. tw_framework/common.py +22 -0
  11. tw_framework/compiler.py +4817 -0
  12. tw_framework/compiler_stats.py +51 -0
  13. tw_framework/dead_code.py +44 -0
  14. tw_framework/dependency_graph.py +91 -0
  15. tw_framework/deploy.py +7 -0
  16. tw_framework/dev.py +9 -0
  17. tw_framework/diagnostics.py +98 -0
  18. tw_framework/dynamic_imports.py +51 -0
  19. tw_framework/error_formatter.py +53 -0
  20. tw_framework/framework.py +3216 -0
  21. tw_framework/hydration.py +129 -0
  22. tw_framework/incremental_cache.py +53 -0
  23. tw_framework/interpreter.py +42 -0
  24. tw_framework/ir.py +171 -0
  25. tw_framework/lexer.py +52 -0
  26. tw_framework/lowering.py +68 -0
  27. tw_framework/parser.py +167 -0
  28. tw_framework/partial_rebuild.py +24 -0
  29. tw_framework/performance_analyzer.py +37 -0
  30. tw_framework/plugin_runtime.py +196 -0
  31. tw_framework/production_optimizer.py +82 -0
  32. tw_framework/reactivity.py +349 -0
  33. tw_framework/render_css.py +22 -0
  34. tw_framework/render_html.py +259 -0
  35. tw_framework/route_optimizer.py +40 -0
  36. tw_framework/router.py +7 -0
  37. tw_framework/runtime_model.py +23 -0
  38. tw_framework/runtime_values.py +31 -0
  39. tw_framework/semantic.py +162 -0
  40. tw_framework/server.py +532 -0
  41. tw_framework/signature.py +51 -0
  42. tw_framework/static_dynamic_auto.py +32 -0
  43. tw_framework/streaming.py +135 -0
  44. tw_framework/test_build_cli_pytest.py +1078 -0
  45. tw_framework/tree_shaking.py +48 -0
  46. tw_framework/twm_parser.py +274 -0
  47. tw_framework-0.2.3.dist-info/METADATA +8 -0
  48. tw_framework-0.2.3.dist-info/RECORD +51 -0
  49. tw_framework-0.2.3.dist-info/WHEEL +5 -0
  50. tw_framework-0.2.3.dist-info/entry_points.txt +2 -0
  51. tw_framework-0.2.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1078 @@
1
+ from __future__ import annotations
2
+
3
+ import http.server
4
+ import json
5
+ import os
6
+ import shutil
7
+ import socketserver
8
+ import stat
9
+ import sys
10
+ import threading
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from tw_framework import cli, compiler, framework, server
16
+ from tw_framework.plugin_runtime import ExtensionManager
17
+ from tw_framework.reactivity import parse_state_block
18
+
19
+
20
+ def _write(path: Path, text: str) -> None:
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ path.write_text(text, encoding="utf-8")
23
+
24
+
25
+ def make_project(tmp_path: Path, *, modular: bool = True, pretty_urls: bool = True) -> Path:
26
+ root = tmp_path / "proj"
27
+ home = root / "[home]"
28
+ (home / "pages").mkdir(parents=True, exist_ok=True)
29
+ (home / "components").mkdir(parents=True, exist_ok=True)
30
+ (home / "layouts").mkdir(parents=True, exist_ok=True)
31
+
32
+ _write(
33
+ root / "tw.config",
34
+ "\n".join(
35
+ [
36
+ "name: Test Site",
37
+ f"pretty_urls: {'true' if pretty_urls else 'false'}",
38
+ f"modular_pipeline: {'true' if modular else 'false'}",
39
+ "search: false",
40
+ "",
41
+ ]
42
+ ),
43
+ )
44
+ _write(root / "package.json", '{"name":"test","private":true}\n')
45
+ _write(root / "vercel.json", "{}\n")
46
+
47
+ _write(
48
+ home / "layouts" / "main.tw",
49
+ "<!doctype html><html><head>{head}{styles}</head><body>{slot}{scripts}</body></html>",
50
+ )
51
+ _write(
52
+ home / "index.tw",
53
+ """
54
+ page {
55
+ title "Home"
56
+ layout "main"
57
+ render static
58
+ }
59
+ BODY { div "Home" }
60
+ """.lstrip(),
61
+ )
62
+ return root
63
+
64
+
65
+ def test_static_route_build_pretty_urls(tmp_path: Path):
66
+ root = make_project(tmp_path, pretty_urls=True)
67
+ home = root / "[home]"
68
+ _write(
69
+ home / "pages" / "about.tw",
70
+ """
71
+ page { title "About" layout "main" render static }
72
+ BODY { h1 "About" }
73
+ """.lstrip(),
74
+ )
75
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
76
+ assert summary.errors == 0
77
+ assert (root / "dist" / "about" / "index.html").exists()
78
+
79
+
80
+ def test_dynamic_route_single_build(tmp_path: Path):
81
+ root = make_project(tmp_path)
82
+ home = root / "[home]"
83
+ _write(
84
+ home / "pages" / "[id].tw",
85
+ """
86
+ page { title "Item" layout "main" render static }
87
+ BODY { h1 "{id}" }
88
+ """.lstrip(),
89
+ )
90
+ _write(home / "pages" / "[id].json", json.dumps([{"id": "a"}, {"id": "b"}]))
91
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
92
+ assert summary.errors == 0
93
+ assert (root / "dist" / "a" / "index.html").exists()
94
+ assert (root / "dist" / "b" / "index.html").exists()
95
+
96
+
97
+ def test_dynamic_route_catch_all_build(tmp_path: Path):
98
+ root = make_project(tmp_path)
99
+ home = root / "[home]"
100
+ _write(
101
+ home / "pages" / "[...x].tw",
102
+ """
103
+ page { title "Catch" layout "main" render static }
104
+ BODY { p "{x}" }
105
+ """.lstrip(),
106
+ )
107
+ _write(home / "pages" / "[...x].json", json.dumps([{"x": "a/b"}]))
108
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
109
+ assert summary.errors == 0
110
+ assert (root / "dist" / "a" / "b" / "index.html").exists()
111
+
112
+
113
+ def test_dynamic_route_optional_catch_all_build(tmp_path: Path):
114
+ root = make_project(tmp_path)
115
+ home = root / "[home]"
116
+ _write(
117
+ home / "pages" / "[[...x]].tw",
118
+ """
119
+ page { title "Opt" layout "main" render static }
120
+ BODY { p "ok" }
121
+ """.lstrip(),
122
+ )
123
+ _write(home / "pages" / "[[...x]].json", json.dumps([{"x": None}]))
124
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
125
+ assert summary.errors == 0
126
+ # optional catch-all can map to `/` (no segments) for null/empty input
127
+ assert (root / "dist" / "index.html").exists()
128
+
129
+
130
+ def test_imported_component_supports_unicode_text(tmp_path: Path):
131
+ root = make_project(tmp_path)
132
+ home = root / "[home]"
133
+ _write(
134
+ home / "components" / "Profile.tw",
135
+ """
136
+ div {
137
+ p "Python • JavaScript • HTML • CSS"
138
+ p "Hello — “world” 🚀"
139
+ }
140
+ """.lstrip(),
141
+ )
142
+ _write(
143
+ home / "index.tw",
144
+ """
145
+ page { title "Home" layout "main" render static }
146
+ BODY {
147
+ import "Profile"
148
+ Profile {}
149
+ }
150
+ """.lstrip(),
151
+ )
152
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
153
+ assert summary.errors == 0
154
+ html = (root / "dist" / "index.html").read_text(encoding="utf-8")
155
+ assert "Python • JavaScript • HTML • CSS" in html
156
+ assert "Hello — “world” 🚀" in html
157
+
158
+
159
+ def test_imported_component_repairs_common_mojibake(tmp_path: Path):
160
+ root = make_project(tmp_path)
161
+ home = root / "[home]"
162
+ _write(
163
+ home / "components" / "Profile.tw",
164
+ """
165
+ div {
166
+ p "Python • JavaScript — HTML"
167
+ }
168
+ """.lstrip(),
169
+ )
170
+ _write(
171
+ home / "index.tw",
172
+ """
173
+ page { title "Home" layout "main" render static }
174
+ BODY {
175
+ import "Profile"
176
+ Profile {}
177
+ }
178
+ """.lstrip(),
179
+ )
180
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
181
+ assert summary.errors == 0
182
+ html = (root / "dist" / "index.html").read_text(encoding="utf-8")
183
+ assert "Python • JavaScript — HTML" in html
184
+
185
+
186
+ def test_component_block_accepts_semicolon_separators(tmp_path: Path):
187
+ root = make_project(tmp_path)
188
+ home = root / "[home]"
189
+ _write(
190
+ home / "components" / "Examples.tw",
191
+ """
192
+ section {
193
+ div {
194
+ class "example-grid"
195
+ div { h3 "Landing Page"; p "Modern UI page" }
196
+ div { h3 "Dashboard"; p "Admin panel UI" }
197
+ }
198
+ }
199
+ """.lstrip(),
200
+ )
201
+ _write(
202
+ home / "index.tw",
203
+ """
204
+ page { title "Home" layout "main" render static }
205
+ BODY {
206
+ import "Examples"
207
+ Examples {}
208
+ }
209
+ """.lstrip(),
210
+ )
211
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
212
+ assert summary.errors == 0
213
+ html = (root / "dist" / "index.html").read_text(encoding="utf-8")
214
+ assert "Landing Page" in html
215
+ assert "Modern UI page" in html
216
+ assert "Dashboard" in html
217
+ assert "Admin panel UI" in html
218
+
219
+
220
+ def test_circular_component_import_detection(tmp_path: Path):
221
+ root = make_project(tmp_path)
222
+ framework.configure_compiler_paths(str(root))
223
+ _write(root / "[home]" / "components" / "A.tw", 'div { import "B" p "A" }')
224
+ _write(root / "[home]" / "components" / "B.tw", 'div { import "A" p "B" }')
225
+ with pytest.raises(compiler.CompilerError) as exc:
226
+ compiler.collect_component_dependencies("A")
227
+ assert "Circular component import" in str(exc.value)
228
+
229
+
230
+ def test_component_not_found_error(tmp_path: Path):
231
+ root = make_project(tmp_path)
232
+ home = root / "[home]"
233
+ _write(
234
+ home / "pages" / "broken.tw",
235
+ """
236
+ page { title "Broken" layout "main" render static }
237
+ BODY { import "Nope" }
238
+ """.lstrip(),
239
+ )
240
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
241
+ assert summary.errors >= 1
242
+
243
+
244
+ def test_component_level_load_directive(tmp_path: Path):
245
+ root = make_project(tmp_path)
246
+ home = root / "[home]"
247
+ _write(home / "styles" / "extra.tss", ".x { color red }")
248
+ _write(
249
+ home / "components" / "Card.tw",
250
+ """
251
+ load @./styles/extra.tss
252
+ div { class "x" text "Card" }
253
+ """.lstrip(),
254
+ )
255
+ _write(
256
+ home / "pages" / "card.tw",
257
+ """
258
+ page { title "Card" layout "main" render static }
259
+ BODY { import "Card" Card {} }
260
+ """.lstrip(),
261
+ )
262
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
263
+ assert summary.errors == 0
264
+ html = (root / "dist" / "card" / "index.html").read_text("utf-8")
265
+ assert "color: red" in html
266
+
267
+
268
+ def test_resolve_source_path_supports_project_and_home_aliases(tmp_path: Path):
269
+ root = make_project(tmp_path)
270
+ framework.configure_compiler_paths(str(root))
271
+ base_dir = str(root / "[home]" / "pages")
272
+ assert compiler.resolve_source_path("page.tss", base_dir) == str(root / "[home]" / "pages" / "page.tss")
273
+ assert compiler.resolve_source_path("@lib/mlkraj.tw", base_dir) == str(root / "lib" / "mlkraj.tw")
274
+ assert compiler.resolve_source_path("@./lib/mlkraj.tw", base_dir) == str(root / "[home]" / "lib" / "mlkraj.tw")
275
+ assert compiler.resolve_source_path("@../style/site.tss", base_dir) == str(root / "[home]" / "style" / "site.tss")
276
+
277
+
278
+ def test_quoted_load_uses_same_folder(tmp_path: Path):
279
+ root = make_project(tmp_path)
280
+ home = root / "[home]"
281
+ (home / "pages" / "apps").mkdir(parents=True, exist_ok=True)
282
+ _write(home / "pages" / "apps" / "page.tss", ".apps-page { color blue }")
283
+ _write(
284
+ home / "pages" / "apps" / "page.tw",
285
+ """
286
+ page { title "Apps" layout "main" render static }
287
+ load "page.tss"
288
+ BODY { div { class "apps-page" text "Apps" } }
289
+ """.lstrip(),
290
+ )
291
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
292
+ assert summary.errors == 0
293
+ html = (root / "dist" / "apps" / "page" / "index.html").read_text("utf-8")
294
+ assert "color: blue" in html
295
+
296
+
297
+ def test_layout_level_load_directive(tmp_path: Path):
298
+ root = make_project(tmp_path)
299
+ home = root / "[home]"
300
+ _write(home / "components" / "Header.tw", 'header { h1 "Header" }')
301
+ _write(
302
+ home / "layouts" / "main.tw",
303
+ "<!doctype html><html><head>{head}{styles}</head><body>\n"
304
+ "load @./components/Header.tw\n"
305
+ "{slot}{scripts}\n"
306
+ "</body></html>",
307
+ )
308
+ _write(
309
+ home / "pages" / "x.tw",
310
+ """
311
+ page {
312
+ title "X"
313
+ layout "main"
314
+ render static
315
+ }
316
+ BODY { p "ok" }
317
+ """.lstrip(),
318
+ )
319
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
320
+ assert summary.errors == 0
321
+ html = (root / "dist" / "x" / "index.html").read_text("utf-8")
322
+ assert "<header>" in html and "Header" in html
323
+
324
+
325
+ def test_layout_chaining_multiple_layouts(tmp_path: Path):
326
+ root = make_project(tmp_path)
327
+ home = root / "[home]"
328
+ _write(home / "layouts" / "outer.tw", "<html><body><main>OUTER{slot}</main></body></html>")
329
+ _write(home / "layouts" / "inner.tw", "<section>INNER{slot}</section>")
330
+ _write(
331
+ home / "pages" / "chain.tw",
332
+ """
333
+ page {
334
+ title "Chain"
335
+ layout "outer > inner"
336
+ render static
337
+ }
338
+ BODY { p "Hello" }
339
+ """.lstrip(),
340
+ )
341
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
342
+ assert summary.errors == 0
343
+ html = (root / "dist" / "chain" / "index.html").read_text("utf-8")
344
+ assert "OUTER" in html and "INNER" in html and "Hello" in html
345
+
346
+
347
+ def test_undefined_symbol_diagnostics_edge_cases(tmp_path: Path):
348
+ root = make_project(tmp_path)
349
+ tw = root / "[home]" / "pages" / "warn.tw"
350
+ _write(
351
+ tw,
352
+ """
353
+ page { title "Warn" layout "main" render static }
354
+ let ok = 1
355
+ BODY {
356
+ if missingCond { div "x" }
357
+ for item in missingList { div { class "{missingAttr}" text "{item}" } }
358
+ }
359
+ """.lstrip(),
360
+ )
361
+ artifacts = compiler.compile_file_pipeline(str(tw), capture_errors=True)
362
+ warnings = [d for d in artifacts.diagnostics if d.get("severity") == "warning"]
363
+ codes = {d.get("code") for d in warnings}
364
+ assert "TW2001" in codes
365
+
366
+
367
+ def test_cli_build_exit_code_zero_clean(tmp_path: Path):
368
+ root = make_project(tmp_path)
369
+ args = type(
370
+ "Args",
371
+ (),
372
+ dict(
373
+ project_root=str(root),
374
+ out_dir=str(root / "dist"),
375
+ force=False,
376
+ workers=1,
377
+ dev=False,
378
+ prod=False,
379
+ watch=False,
380
+ analyze=False,
381
+ clean=False,
382
+ no_minify=True,
383
+ fail_on_warnings=False,
384
+ strict=False,
385
+ ),
386
+ )()
387
+ assert cli.command_build(args) == 0
388
+
389
+
390
+ def test_cli_build_prod_fails_on_warnings(tmp_path: Path):
391
+ root = make_project(tmp_path)
392
+ home = root / "[home]"
393
+ _write(home / "pages" / "warn.tw", 'page { title "W" layout "main" render static } BODY { p "{missing}" }')
394
+ args = type(
395
+ "Args",
396
+ (),
397
+ dict(
398
+ project_root=str(root),
399
+ out_dir=str(root / "dist"),
400
+ force=True,
401
+ workers=1,
402
+ dev=False,
403
+ prod=True,
404
+ watch=False,
405
+ analyze=False,
406
+ clean=False,
407
+ no_minify=True,
408
+ fail_on_warnings=False,
409
+ strict=False,
410
+ ),
411
+ )()
412
+ assert cli.command_build(args) == 1
413
+
414
+
415
+ def test_cli_check_json_shape(tmp_path: Path, capsys: pytest.CaptureFixture[str]):
416
+ root = make_project(tmp_path)
417
+ tw = root / "[home]" / "pages" / "ok.tw"
418
+ _write(tw, 'page { title "Ok" layout "main" render static } BODY { p "ok" }')
419
+ args = type("Args", (), dict(file=str(tw), out=None, include_ast=False, include_ir=False))()
420
+ code = cli.command_check(args)
421
+ out = capsys.readouterr().out
422
+ payload = json.loads(out)
423
+ assert code == 0
424
+ assert set(payload.keys()) >= {"diagnostics", "metadata", "dependencies", "route_path", "pipeline", "summary"}
425
+ assert payload["summary"]["errors"] == 0
426
+
427
+
428
+ def test_cli_check_include_ast_and_ir_are_json_serializable(tmp_path: Path, capsys: pytest.CaptureFixture[str]):
429
+ root = make_project(tmp_path)
430
+ tw = root / "[home]" / "pages" / "ok.tw"
431
+ _write(
432
+ tw,
433
+ """
434
+ page { title "Ok" layout "main" render static }
435
+ load @../style/extra.tss
436
+ BODY { p "ok" }
437
+ """.lstrip(),
438
+ )
439
+ _write(root / "[home]" / "style" / "extra.tss", ".ok { color red }")
440
+ args = type("Args", (), dict(file=str(tw), out=None, include_ast=True, include_ir=True))()
441
+ code = cli.command_check(args)
442
+ out = capsys.readouterr().out
443
+ payload = json.loads(out)
444
+ assert code == 0
445
+ assert "ast" in payload and "ir" in payload
446
+ assert isinstance(payload["ast"]["loaded_sheets"], list)
447
+ assert payload["metadata"]["diagnostic_summary"]["errors"] == 0
448
+
449
+
450
+ def test_compile_pipeline_reports_failure_phase(tmp_path: Path):
451
+ root = make_project(tmp_path)
452
+ tw = root / "[home]" / "pages" / "broken.tw"
453
+ _write(tw, 'page { title "Broken" layout "main" render static } BODY { p "oops }')
454
+ artifacts = compiler.compile_file_pipeline(str(tw), capture_errors=True)
455
+ assert artifacts.diagnostics
456
+ assert artifacts.diagnostics[0]["phase"] in {"tokenize", "parse"}
457
+ assert artifacts.metadata["diagnostic_summary"]["errors"] >= 1
458
+
459
+
460
+ def test_cli_doctor_smoke(tmp_path: Path):
461
+ root = make_project(tmp_path)
462
+ args = type("Args", (), dict(project_root=str(root)))()
463
+ assert cli.command_doctor(args) == 0
464
+
465
+
466
+ def test_cli_create_scaffold_generates_files(tmp_path: Path):
467
+ target_parent = tmp_path / "parent"
468
+ target_parent.mkdir()
469
+ cli.create_project("hello", str(target_parent))
470
+ root = target_parent / "hello"
471
+ assert (root / "tw.config").exists()
472
+ assert (root / "[home]" / "index.tw").exists()
473
+ assert (root / ".gitignore").exists()
474
+
475
+
476
+ def test_incremental_build_cache_skips(tmp_path: Path):
477
+ root = make_project(tmp_path)
478
+ out_dir = root / "dist"
479
+ first = framework.build_hidden_site(str(root), str(out_dir), workers=1, minify=False)
480
+ second = framework.build_hidden_site(str(root), str(out_dir), workers=1, minify=False)
481
+ assert first.errors == 0
482
+ assert second.errors == 0
483
+ assert second.skipped >= 1
484
+
485
+
486
+ def test_dependency_graph_contains_component_deps(tmp_path: Path):
487
+ root = make_project(tmp_path)
488
+ home = root / "[home]"
489
+ _write(home / "components" / "Thing.tw", 'div { p "T" }')
490
+ _write(home / "pages" / "uses.tw", 'page { title "U" layout "main" render static } BODY { import "Thing" Thing {} }')
491
+ framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
492
+ graph_path = root / ".tw" / "cache" / "dependency-graph.json"
493
+ payload = json.loads(graph_path.read_text("utf-8"))
494
+ forward = payload["forward"]
495
+ # At least one page should include the component .tw path as a dependency
496
+ assert any("Thing.tw" in " ".join(deps) for deps in forward.values())
497
+
498
+
499
+ def test_parse_state_block_basic():
500
+ state = parse_state_block(
501
+ """
502
+ state {
503
+ count 0
504
+ name "World"
505
+ items [1,2,3]
506
+ }
507
+ """.lstrip()
508
+ )
509
+ assert state["count"] == 0
510
+ assert state["name"] == "World"
511
+ assert state["items"] == [1, 2, 3]
512
+
513
+
514
+ def test_css_normalization_edge_cases():
515
+ css = framework.compiler.render_css( # type: ignore[attr-defined]
516
+ compiler.build_tss_ast_from_text(
517
+ """
518
+ .x {
519
+ padding 0
520
+ margin -2
521
+ width 50%
522
+ }
523
+ """
524
+ )
525
+ )
526
+ assert "padding: 0;" in css
527
+ assert "margin: -2px;" in css
528
+ assert "width: 50%;" in css
529
+
530
+
531
+ def test_minifiers_do_not_crash():
532
+ assert compiler.minify_html_content("<div> \n <span> x </span>\n</div>")
533
+ assert compiler.minify_css_content("/*x*/ .a { color : red ; }")
534
+ assert compiler.minify_js_content("//c\n/*x*/\nvar a = 1;\n")
535
+
536
+
537
+ def test_dynamic_json_malformed_returns_empty_and_warns(tmp_path: Path, capsys: pytest.CaptureFixture[str]):
538
+ root = make_project(tmp_path)
539
+ home = root / "[home]"
540
+ _write(home / "pages" / "[id].tw", 'page { title "D" layout "main" render static } BODY { p "x" }')
541
+ _write(home / "pages" / "[id].json", "{not: json")
542
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
543
+ err = capsys.readouterr().err
544
+ assert summary.errors == 0 # malformed dynamic items should not crash the whole build
545
+ assert "Failed to parse dynamic route JSON" in err
546
+
547
+
548
+ def test_route_collision_warning_and_strict_error(tmp_path: Path):
549
+ root = make_project(tmp_path)
550
+ home = root / "[home]"
551
+ # Static /about
552
+ _write(home / "pages" / "about.tw", 'page { title "A" layout "main" render static } BODY { p "about" }')
553
+ # Dynamic [slug] that also emits /about
554
+ _write(home / "pages" / "[slug].tw", 'page { title "S" layout "main" render static } BODY { p "{slug}" }')
555
+ _write(home / "pages" / "[slug].json", json.dumps([{"slug": "about"}]))
556
+
557
+ non_strict = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False, strict=False)
558
+ assert non_strict.errors == 0
559
+ assert non_strict.route_collisions >= 1
560
+
561
+ strict = framework.build_hidden_site(str(root), str(root / "dist2"), workers=1, minify=False, strict=True)
562
+ assert strict.errors >= 1
563
+ assert strict.route_collisions >= 1
564
+
565
+
566
+ def test_dynamic_json_schema_validation_is_error(tmp_path: Path):
567
+ root = make_project(tmp_path)
568
+ home = root / "[home]"
569
+ _write(home / "pages" / "[id].tw", 'page { title "D" layout "main" render static } BODY { p "{id}" }')
570
+ # Wrong schema (not list / not {items:[]})
571
+ _write(home / "pages" / "[id].json", json.dumps({"bad": True}))
572
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
573
+ assert summary.errors >= 1
574
+
575
+
576
+ def test_backup_before_write_manifest_and_dependency_graph(tmp_path: Path):
577
+ root = make_project(tmp_path)
578
+ framework.configure_compiler_paths(str(root))
579
+ # Seed old files
580
+ old_manifest = {"version": compiler.BUILD_MANIFEST_VERSION, "pages": {"x": {"outputs": []}}}
581
+ old_graph = {"version": compiler.DEPENDENCY_GRAPH_VERSION, "forward": {}, "reverse": {}, "metadata": {}}
582
+ (root / ".tw" / "manifest").mkdir(parents=True, exist_ok=True)
583
+ (root / ".tw" / "cache").mkdir(parents=True, exist_ok=True)
584
+ (root / ".tw" / "manifest" / "build-manifest.json").write_text(json.dumps(old_manifest), "utf-8")
585
+ (root / ".tw" / "cache" / "dependency-graph.json").write_text(json.dumps(old_graph), "utf-8")
586
+
587
+ compiler.save_build_manifest({"pages": {}})
588
+ compiler.save_dependency_graph({"forward": {}, "metadata": {}})
589
+
590
+ assert (root / ".tw" / "manifest" / "build-manifest.json.bak").exists()
591
+ assert (root / ".tw" / "cache" / "dependency-graph.json.bak").exists()
592
+
593
+
594
+ def test_chunk_cache_is_bounded(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
595
+ root = make_project(tmp_path)
596
+ framework.configure_compiler_paths(str(root))
597
+ monkeypatch.setenv("TW_CHUNK_CACHE_MAX", "2")
598
+ framework.invalidate_compiler_caches()
599
+ compiler.write_chunk("a", "js")
600
+ compiler.write_chunk("b", "js")
601
+ compiler.write_chunk("c", "js")
602
+ assert len(compiler._CHUNK_CACHE) <= 2
603
+
604
+
605
+ def test_env_parsing_inline_comments_and_quotes(tmp_path: Path):
606
+ root = make_project(tmp_path)
607
+ env_path = root / ".env"
608
+ env_path.write_text('A=1 # comment\nB="x # not comment"\nC=\'y # not comment\'\n', "utf-8")
609
+ env = framework.load_project_env(str(root), "development")
610
+ assert env["A"] == "1"
611
+ assert env["B"] == "x # not comment"
612
+ assert env["C"] == "y # not comment"
613
+
614
+
615
+ def test_build_package_json_scaffolds_dependency_sections():
616
+ package_json = json.loads(cli.build_package_json("Demo App"))
617
+ assert package_json["name"] == "demo-app"
618
+ assert package_json["engines"]["node"] == ">=18"
619
+ assert package_json["dependencies"] == {}
620
+ assert package_json["devDependencies"] == {}
621
+
622
+
623
+ def test_twm_async_helpers_support_http_env_and_pkg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
624
+ if shutil.which("node") is None:
625
+ pytest.skip("Node.js is required for `.twm` runtime tests")
626
+
627
+ root = make_project(tmp_path)
628
+ home = root / "[home]"
629
+ handler_path = home / "api" / "proxy.twm"
630
+ _write(
631
+ handler_path,
632
+ """
633
+ async fn post(request) {
634
+ const response = await http.post(
635
+ env.require("TW_TEST_TARGET") + "/echo",
636
+ { hello: "world", incoming: request.body },
637
+ { timeout: 3000 }
638
+ );
639
+ return {
640
+ json: {
641
+ ok: response.ok,
642
+ status: response.status,
643
+ echoed: response.data.received,
644
+ secret: secrets.require("TW_TEST_SECRET"),
645
+ sep: pkg.require("path").sep
646
+ }
647
+ };
648
+ }
649
+ """.lstrip(),
650
+ )
651
+
652
+ class EchoHandler(http.server.BaseHTTPRequestHandler):
653
+ def do_POST(self):
654
+ length = int(self.headers.get("Content-Length", "0") or "0")
655
+ payload = self.rfile.read(length)
656
+ data = json.loads(payload.decode("utf-8"))
657
+ body = json.dumps({"received": data}).encode("utf-8")
658
+ self.send_response(200)
659
+ self.send_header("Content-Type", "application/json")
660
+ self.send_header("Content-Length", str(len(body)))
661
+ self.end_headers()
662
+ self.wfile.write(body)
663
+
664
+ def log_message(self, fmt, *args):
665
+ return
666
+
667
+ server_instance = socketserver.TCPServer(("127.0.0.1", 0), EchoHandler)
668
+ server_thread = threading.Thread(target=server_instance.serve_forever, daemon=True)
669
+ server_thread.start()
670
+
671
+ monkeypatch.setenv("TW_TEST_TARGET", f"http://127.0.0.1:{server_instance.server_address[1]}")
672
+ monkeypatch.setenv("TW_TEST_SECRET", "top-secret")
673
+ framework.configure_compiler_paths(str(root))
674
+
675
+ try:
676
+ response = framework.execute_twm_api_handler(
677
+ str(handler_path),
678
+ "POST",
679
+ "/api/proxy",
680
+ {"Content-Type": "application/json"},
681
+ {"user": "demo"},
682
+ )
683
+ finally:
684
+ server_instance.shutdown()
685
+ server_thread.join(timeout=2)
686
+ server_instance.server_close()
687
+
688
+ payload = json.loads(response["body"].decode("utf-8"))
689
+ assert response["status"] == 200
690
+ assert payload["ok"] is True
691
+ assert payload["status"] == 200
692
+ assert payload["echoed"] == {"hello": "world", "incoming": {"user": "demo"}}
693
+ assert payload["secret"] == "top-secret"
694
+ assert payload["sep"] in {"/", "\\"}
695
+
696
+
697
+ def test_global_config_atomic_write(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
698
+ # Redirect global config to temp paths
699
+ cfg_dir = tmp_path / "cfg"
700
+ cfg_file = cfg_dir / "config.json"
701
+ monkeypatch.setattr(cli, "GLOBAL_CONFIG_DIR", str(cfg_dir))
702
+ monkeypatch.setattr(cli, "GLOBAL_CONFIG_FILE", str(cfg_file))
703
+ cli.save_global_config({"vercel_token": "t"})
704
+ assert cfg_file.exists()
705
+ data = json.loads(cfg_file.read_text("utf-8"))
706
+ assert data["vercel_token"] == "t"
707
+ assert stat.S_IMODE(os.stat(cfg_dir).st_mode) == 0o700
708
+ assert stat.S_IMODE(os.stat(cfg_file).st_mode) == 0o600
709
+
710
+
711
+ def test_file_watcher_rescan_ticks_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
712
+ root = make_project(tmp_path)
713
+ framework.configure_compiler_paths(str(root))
714
+ project = framework.TWProject(str(root))
715
+ state = framework.TWDevState(project)
716
+ monkeypatch.setenv("TW_WATCH_RESCAN_TICKS", "1")
717
+ monkeypatch.setenv("TW_WATCH_BACKEND", "poll")
718
+ watcher = framework.TWFileWatcher(state, interval=0.2)
719
+ assert watcher.rescan_ticks == 1
720
+
721
+
722
+ def test_file_watcher_inotify_setup_smoke(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
723
+ if not sys.platform.startswith("linux"):
724
+ pytest.skip("inotify backend is Linux-only")
725
+ root = make_project(tmp_path)
726
+ framework.configure_compiler_paths(str(root))
727
+ project = framework.TWProject(str(root))
728
+ state = framework.TWDevState(project)
729
+ monkeypatch.setenv("TW_WATCH_BACKEND", "inotify")
730
+ watcher = framework.TWFileWatcher(state, interval=0.2)
731
+ ok = watcher._inotify_setup()
732
+ if not ok:
733
+ pytest.skip("inotify not available in this environment")
734
+ assert watcher._inotify_fd is not None
735
+ # Cleanup: close fd to avoid leaking across tests
736
+ os.close(watcher._inotify_fd)
737
+ watcher._inotify_fd = None
738
+
739
+
740
+ def test_thread_safety_build_workers(tmp_path: Path):
741
+ root = make_project(tmp_path)
742
+ home = root / "[home]"
743
+ _write(home / "components" / "Shared.tw", 'div { p "shared" }')
744
+ for i in range(12):
745
+ _write(
746
+ home / "pages" / f"p{i}.tw",
747
+ f"""
748
+ page {{
749
+ title "P{i}"
750
+ layout "main"
751
+ render static
752
+ }}
753
+ BODY {{ import "Shared" Shared {{}} }}
754
+ """.lstrip(),
755
+ )
756
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=4, minify=False)
757
+ assert summary.errors == 0
758
+
759
+
760
+ def test_component_import_path_traversal_rejected(tmp_path: Path):
761
+ root = make_project(tmp_path)
762
+ framework.configure_compiler_paths(str(root))
763
+ with pytest.raises(compiler.CompilerError):
764
+ compiler.resolve_component_path("../secrets")
765
+
766
+
767
+ def test_component_name_absolute_and_null_bytes_rejected(tmp_path: Path):
768
+ root = make_project(tmp_path)
769
+ framework.configure_compiler_paths(str(root))
770
+ with pytest.raises(compiler.CompilerError):
771
+ compiler.resolve_component_path("/tmp/secret")
772
+ with pytest.raises(compiler.CompilerError):
773
+ compiler.resolve_component_path("Bad\x00Name")
774
+
775
+
776
+ def test_extension_manager_warns_before_executing_plugins(tmp_path: Path, caplog: pytest.LogCaptureFixture):
777
+ root = make_project(tmp_path)
778
+ plugin_dir = root / "[home]" / "plugins"
779
+ _write(
780
+ plugin_dir / "sample.py",
781
+ """
782
+ def register(api):
783
+ api.register_hook("beforeBuild", lambda payload: payload)
784
+ """.lstrip(),
785
+ )
786
+ caplog.set_level("WARNING")
787
+ manager = ExtensionManager(str(root), config={}, env={}).refresh()
788
+ assert manager.extensions
789
+ assert "Executing TW extensions from project code" in caplog.text
790
+ assert "sample.py" in caplog.text
791
+
792
+
793
+ def test_extension_allowlist_blocks_unlisted_plugins(tmp_path: Path):
794
+ root = make_project(tmp_path)
795
+ plugin_dir = root / "[home]" / "plugins"
796
+ marker = root / "executed.txt"
797
+ _write(
798
+ plugin_dir / "allowed.py",
799
+ """
800
+ def register(api):
801
+ api.register_hook("beforeBuild", lambda payload: dict(payload, allowed=True))
802
+ """.lstrip(),
803
+ )
804
+ _write(
805
+ plugin_dir / "blocked.py",
806
+ f"""
807
+ from pathlib import Path
808
+ Path({str(marker)!r}).write_text("blocked", encoding="utf-8")
809
+
810
+ def register(api):
811
+ api.register_hook("beforeBuild", lambda payload: payload)
812
+ """.lstrip(),
813
+ )
814
+ manager = ExtensionManager(
815
+ str(root),
816
+ config={"plugin_allowlist": ["allowed.py"]},
817
+ env={},
818
+ ).refresh()
819
+ assert [ext.name for ext in manager.extensions] == ["allowed"]
820
+ assert not marker.exists()
821
+
822
+
823
+ def test_create_project_gitignore_includes_cache_dir(tmp_path: Path):
824
+ target_parent = tmp_path / "parent2"
825
+ target_parent.mkdir()
826
+ cli.create_project("hello2", str(target_parent))
827
+ gitignore = (target_parent / "hello2" / ".gitignore").read_text("utf-8")
828
+ assert ".tw-cache/" in gitignore
829
+
830
+
831
+ def test_load_config_supports_nested_blocks(tmp_path: Path):
832
+ root = make_project(tmp_path)
833
+ _write(
834
+ root / "tw.config",
835
+ """
836
+ name: Nested Site
837
+ security:
838
+ headers:
839
+ X-Frame-Options: DENY
840
+ cookies:
841
+ secure: auto
842
+ ssr:
843
+ cache_max: 42
844
+ """.lstrip(),
845
+ )
846
+ framework.configure_compiler_paths(str(root))
847
+ config = compiler.load_config()
848
+ assert config["security"]["headers"]["X-Frame-Options"] == "DENY"
849
+ assert config["cookies"]["secure"] == "auto"
850
+ assert config["ssr"]["cache_max"] == 42
851
+
852
+
853
+ def test_page_parser_accepts_cache_controls(tmp_path: Path):
854
+ root = make_project(tmp_path)
855
+ page_path = root / "[home]" / "pages" / "edge.tw"
856
+ _write(
857
+ page_path,
858
+ """
859
+ page {
860
+ title "Edge"
861
+ layout "main"
862
+ render edge
863
+ revalidate 60
864
+ cache_by cookie:session_id
865
+ cache_size 3
866
+ }
867
+ BODY { div "edge" }
868
+ """.lstrip(),
869
+ )
870
+ framework.configure_compiler_paths(str(root))
871
+ page_ast = compiler.load_page_ast_from_file(str(page_path))
872
+ assert page_ast.cache_by == "cookie:session_id"
873
+ assert page_ast.cache_size == 3
874
+
875
+
876
+ def test_rate_limit_is_opt_in_middleware_primitive(tmp_path: Path):
877
+ root = make_project(tmp_path)
878
+ _write(
879
+ root / "middleware.tw",
880
+ """
881
+ use {
882
+ match "/api/**"
883
+ rate_limit {
884
+ requests 1
885
+ window 60
886
+ }
887
+ }
888
+ """.lstrip(),
889
+ )
890
+ framework.configure_compiler_paths(str(root))
891
+ project = framework.TWProject(str(root))
892
+ first = project.apply_middleware("/api/users", {"Cookie": ""}, request_meta={"client_ip": "127.0.0.1"})
893
+ second = project.apply_middleware("/api/users", {"Cookie": ""}, request_meta={"client_ip": "127.0.0.1"})
894
+ assert first["response"] is None
895
+ assert second["response"]["status"] == 429
896
+
897
+
898
+ def test_render_cookie_header_honors_secure_mode_auto():
899
+ secure = framework.render_cookie_header(
900
+ "session",
901
+ "abc",
902
+ config={"cookies": {"secure": "auto"}},
903
+ request_headers={"X-Forwarded-Proto": "https"},
904
+ server_port=443,
905
+ )
906
+ insecure = framework.render_cookie_header(
907
+ "session",
908
+ "abc",
909
+ config={"cookies": {"secure": "auto"}},
910
+ request_headers={"X-Forwarded-Proto": "http"},
911
+ server_port=80,
912
+ )
913
+ assert "Secure" in secure
914
+ assert "Secure" not in insecure
915
+
916
+
917
+ def test_is_path_within_blocks_prefix_confusion(tmp_path: Path):
918
+ root = tmp_path / "root"
919
+ root.mkdir()
920
+ sibling = tmp_path / "root-evil" / "file.txt"
921
+ sibling.parent.mkdir()
922
+ sibling.write_text("x", encoding="utf-8")
923
+ assert framework.is_path_within(str(root), str(root / "ok.txt"))
924
+ assert not framework.is_path_within(str(root), str(sibling))
925
+
926
+
927
+ def test_ssr_cache_respects_namespace_limit():
928
+ cache = server.SSRCache(max_entries=10)
929
+ cache.set("a", b"1", None, namespace="/dashboard", namespace_max=2)
930
+ cache.set("b", b"2", None, namespace="/dashboard", namespace_max=2)
931
+ cache.set("c", b"3", None, namespace="/dashboard", namespace_max=2)
932
+ assert cache.get("a") is None
933
+ assert cache.get("b") == b"2"
934
+ assert cache.get("c") == b"3"
935
+
936
+
937
+ def test_twm_modules_compile_and_do_not_auto_execute(tmp_path: Path):
938
+ root = make_project(tmp_path)
939
+ home = root / "[home]"
940
+
941
+ # External `.twm` module loaded via the universal `load` keyword.
942
+ _write(
943
+ home / "lib" / "auth.twm",
944
+ """
945
+ fn login() {
946
+ // only runs when called explicitly from an event
947
+ console.log("login");
948
+ }
949
+ """.lstrip(),
950
+ )
951
+
952
+ # Local SCRIPT { ... } block should behave like a `.twm` module (registered, not executed).
953
+ _write(
954
+ home / "pages" / "mod.tw",
955
+ """
956
+ load @./lib/auth.twm
957
+
958
+ SCRIPT {
959
+ fn hello() {
960
+ console.log("hello");
961
+ }
962
+ }
963
+
964
+ page { title "M" layout "main" render static }
965
+ BODY {
966
+ button {
967
+ onclick login
968
+ text "Login"
969
+ }
970
+ }
971
+ """.lstrip(),
972
+ )
973
+
974
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
975
+ assert summary.errors == 0
976
+
977
+ html_path = root / "dist" / "mod" / "index.html"
978
+ assert html_path.exists()
979
+ html = html_path.read_text("utf-8")
980
+ assert "onclick" in html
981
+ assert "__twInvoke" in html or "window.__twInvoke" in html
982
+
983
+ chunks_dir = root / "dist" / "_tw" / "static" / "chunks"
984
+ js_files = list(chunks_dir.glob("*.js"))
985
+ assert js_files, "Expected TW to emit at least one JS chunk for `.twm` bundle"
986
+ joined = "\n".join(p.read_text("utf-8") for p in js_files)
987
+ assert "__twRegistry" in joined
988
+ assert "function login" in joined
989
+ assert "function hello" in joined
990
+
991
+
992
+ def test_on_load_init_injects_explicit_invoke(tmp_path: Path):
993
+ root = make_project(tmp_path)
994
+ home = root / "[home]"
995
+
996
+ _write(
997
+ home / "lib" / "init.twm",
998
+ """
999
+ fn init() {
1000
+ console.log("init ran");
1001
+ }
1002
+ """.lstrip(),
1003
+ )
1004
+
1005
+ _write(
1006
+ home / "pages" / "init.tw",
1007
+ """
1008
+ load @./lib/init.twm
1009
+
1010
+ on load init init
1011
+
1012
+ page { title "Init" layout "main" render static }
1013
+ BODY {
1014
+ div { text "Hello" }
1015
+ }
1016
+ """.lstrip(),
1017
+ )
1018
+
1019
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
1020
+ assert summary.errors == 0
1021
+
1022
+ html_path = root / "dist" / "init" / "index.html"
1023
+ assert html_path.exists()
1024
+ html = html_path.read_text("utf-8")
1025
+ assert "__twInvoke" in html
1026
+ assert "DOMContentLoaded" in html
1027
+ assert '"init"' in html or "'init'" in html
1028
+
1029
+
1030
+ def test_declarative_script_tag_strategies(tmp_path: Path):
1031
+ root = make_project(tmp_path)
1032
+ home = root / "[home]"
1033
+
1034
+ _write(
1035
+ home / "pages" / "scripts.tw",
1036
+ """
1037
+ page { title "Scripts" layout "main" render static }
1038
+ BODY {
1039
+ script { src "https://example.com/a.js" strategy beforeInteractive }
1040
+ script { src "https://example.com/b.js" strategy afterInteractive }
1041
+ script { src "https://example.com/c.js" strategy lazyOnload }
1042
+ div { text "ok" }
1043
+ }
1044
+ """.lstrip(),
1045
+ )
1046
+
1047
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
1048
+ assert summary.errors == 0
1049
+
1050
+ html_path = root / "dist" / "scripts" / "index.html"
1051
+ html = html_path.read_text("utf-8")
1052
+ # beforeInteractive should land in <head>
1053
+ assert "<head>" in html
1054
+ assert "https://example.com/a.js" in html.split("</head>", 1)[0]
1055
+ # afterInteractive & lazyOnload should be deferred via loader script
1056
+ assert "https://example.com/b.js" in html
1057
+ assert "https://example.com/c.js" in html
1058
+ assert "DOMContentLoaded" in html
1059
+ assert "addEventListener('load'" in html or "addEventListener(\"load\"" in html
1060
+
1061
+
1062
+ def test_raw_script_blocks_disabled_by_default(tmp_path: Path):
1063
+ root = make_project(tmp_path)
1064
+ home = root / "[home]"
1065
+
1066
+ _write(
1067
+ home / "pages" / "raw.tw",
1068
+ """
1069
+ page { title "Raw" layout "main" render static }
1070
+ BODY {
1071
+ script { console.log("should not run") }
1072
+ div { text "x" }
1073
+ }
1074
+ """.lstrip(),
1075
+ )
1076
+
1077
+ summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
1078
+ assert summary.errors >= 1