create-caspian-app 1.0.21 → 1.1.0
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.
- package/README.md +112 -93
- package/dist/.github/copilot-instructions.md +24 -25
- package/dist/AGENTS.md +20 -20
- package/dist/index.js +1 -1
- package/dist/main.py +304 -267
- package/dist/public/js/pp-reactive-v2.min.js +1 -1
- package/dist/tests/README.md +1 -1
- package/package.json +2 -2
package/dist/main.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from casp.components_compiler import transform_components
|
|
2
2
|
from casp.html_native import (
|
|
3
|
+
_ESCAPED_BRACE_PLACEHOLDER_RE,
|
|
3
4
|
mask_escaped_brace_entities,
|
|
4
5
|
parse_fragment,
|
|
5
6
|
restore_escaped_brace_entities,
|
|
@@ -9,6 +10,7 @@ import asyncio
|
|
|
9
10
|
import inspect
|
|
10
11
|
import os
|
|
11
12
|
import importlib.util
|
|
13
|
+
import re
|
|
12
14
|
import secrets
|
|
13
15
|
import traceback
|
|
14
16
|
import json
|
|
@@ -20,8 +22,6 @@ from fastapi import (
|
|
|
20
22
|
Request,
|
|
21
23
|
Response,
|
|
22
24
|
WebSocket,
|
|
23
|
-
WebSocketDisconnect,
|
|
24
|
-
status,
|
|
25
25
|
)
|
|
26
26
|
from fastapi.responses import (
|
|
27
27
|
RedirectResponse,
|
|
@@ -49,8 +49,7 @@ from casp.rpc import register_rpc_routes, rpc_limiter
|
|
|
49
49
|
from casp.layout import (
|
|
50
50
|
render_with_nested_layouts,
|
|
51
51
|
compile_template,
|
|
52
|
-
|
|
53
|
-
render_page,
|
|
52
|
+
_finalize_page_region,
|
|
54
53
|
_runtime_injections,
|
|
55
54
|
_runtime_metadata,
|
|
56
55
|
)
|
|
@@ -743,180 +742,25 @@ class RequestDiagnosticsMiddleware:
|
|
|
743
742
|
# ====
|
|
744
743
|
# WebSocket Routes (optional - gated by caspian.config.json `websocket`)
|
|
745
744
|
# ====
|
|
746
|
-
WEBSOCKET_PATH = "/ws/live"
|
|
747
|
-
PUBLIC_WEBSOCKET_PATH = "/ws/public"
|
|
748
|
-
WEBSOCKET_IDLE_TIMEOUT_SECONDS = max(
|
|
749
|
-
10,
|
|
750
|
-
int(os.getenv('WEBSOCKET_IDLE_TIMEOUT_SECONDS', 120)),
|
|
751
|
-
)
|
|
752
|
-
MAX_WEBSOCKET_MESSAGE_BYTES = max(
|
|
753
|
-
256,
|
|
754
|
-
int(os.getenv('MAX_WEBSOCKET_MESSAGE_BYTES', 4096)),
|
|
755
|
-
)
|
|
756
|
-
# Messages one connection may send per rolling window. Each accepted message
|
|
757
|
-
# fans out to every socket in the pool, so an unthrottled client turns a single
|
|
758
|
-
# cheap connection into a broadcast flood against everyone else.
|
|
759
|
-
MAX_WEBSOCKET_MESSAGES_PER_WINDOW = max(
|
|
760
|
-
1,
|
|
761
|
-
int(os.getenv('MAX_WEBSOCKET_MESSAGES_PER_WINDOW', 20)),
|
|
762
|
-
)
|
|
763
|
-
WEBSOCKET_RATE_WINDOW_SECONDS = max(
|
|
764
|
-
1,
|
|
765
|
-
int(os.getenv('WEBSOCKET_RATE_WINDOW_SECONDS', 10)),
|
|
766
|
-
)
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
class WebSocketMessageRate:
|
|
770
|
-
"""Sliding-window send budget for a single connection.
|
|
771
|
-
|
|
772
|
-
Kept per-socket rather than per-IP: the pool is the shared resource being
|
|
773
|
-
protected, and one abusive connection should not be able to spend another
|
|
774
|
-
client's budget.
|
|
775
|
-
"""
|
|
776
|
-
|
|
777
|
-
def __init__(self, limit: int, window_seconds: int):
|
|
778
|
-
self._limit = limit
|
|
779
|
-
self._window_seconds = window_seconds
|
|
780
|
-
self._timestamps: list[float] = []
|
|
781
|
-
|
|
782
|
-
def allow(self) -> bool:
|
|
783
|
-
now = time.monotonic()
|
|
784
|
-
cutoff = now - self._window_seconds
|
|
785
|
-
self._timestamps = [t for t in self._timestamps if t > cutoff]
|
|
786
|
-
|
|
787
|
-
if len(self._timestamps) >= self._limit:
|
|
788
|
-
return False
|
|
789
|
-
|
|
790
|
-
self._timestamps.append(now)
|
|
791
|
-
return True
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
async def _run_websocket_channel(
|
|
795
|
-
websocket: WebSocket,
|
|
796
|
-
manager: Any,
|
|
797
|
-
payload: dict[str, Any] | None,
|
|
798
|
-
ready_message: str,
|
|
799
|
-
):
|
|
800
|
-
if not await manager.connect(websocket):
|
|
801
|
-
# Pool at capacity; `connect` already closed with 1013 (try again later).
|
|
802
|
-
return
|
|
803
|
-
|
|
804
|
-
message_rate = WebSocketMessageRate(
|
|
805
|
-
MAX_WEBSOCKET_MESSAGES_PER_WINDOW,
|
|
806
|
-
WEBSOCKET_RATE_WINDOW_SECONDS,
|
|
807
|
-
)
|
|
808
|
-
ready_payload: dict[str, Any] = {
|
|
809
|
-
"type": "ready",
|
|
810
|
-
"message": ready_message,
|
|
811
|
-
}
|
|
812
|
-
if payload is not None:
|
|
813
|
-
ready_payload["payload"] = payload
|
|
814
|
-
await websocket.send_json(ready_payload)
|
|
815
|
-
|
|
816
|
-
try:
|
|
817
|
-
while True:
|
|
818
|
-
try:
|
|
819
|
-
raw_message = await asyncio.wait_for(
|
|
820
|
-
websocket.receive_text(),
|
|
821
|
-
timeout=WEBSOCKET_IDLE_TIMEOUT_SECONDS,
|
|
822
|
-
)
|
|
823
|
-
except asyncio.TimeoutError:
|
|
824
|
-
await websocket.close(code=status.WS_1000_NORMAL_CLOSURE)
|
|
825
|
-
return
|
|
826
|
-
|
|
827
|
-
if len(raw_message.encode("utf-8")) > MAX_WEBSOCKET_MESSAGE_BYTES:
|
|
828
|
-
await websocket.close(code=status.WS_1009_MESSAGE_TOO_BIG)
|
|
829
|
-
return
|
|
830
|
-
|
|
831
|
-
# Checked before parsing so a flood costs nothing but the read.
|
|
832
|
-
if not message_rate.allow():
|
|
833
|
-
await websocket.send_json({
|
|
834
|
-
"type": "error",
|
|
835
|
-
"message": "Too many messages. Slow down.",
|
|
836
|
-
})
|
|
837
|
-
continue
|
|
838
|
-
|
|
839
|
-
try:
|
|
840
|
-
message = json.loads(raw_message)
|
|
841
|
-
except json.JSONDecodeError:
|
|
842
|
-
await websocket.send_json({
|
|
843
|
-
"type": "error",
|
|
844
|
-
"message": "Messages must be valid JSON.",
|
|
845
|
-
})
|
|
846
|
-
continue
|
|
847
|
-
|
|
848
|
-
if not isinstance(message, dict):
|
|
849
|
-
await websocket.send_json({
|
|
850
|
-
"type": "error",
|
|
851
|
-
"message": "Messages must be JSON objects.",
|
|
852
|
-
})
|
|
853
|
-
continue
|
|
854
|
-
|
|
855
|
-
message_type = str(message.get("type", "message"))
|
|
856
|
-
if message_type == "ping":
|
|
857
|
-
await websocket.send_json({"type": "pong", "time": int(time.time())})
|
|
858
|
-
continue
|
|
859
|
-
|
|
860
|
-
text = str(message.get("text", "")).strip()
|
|
861
|
-
if not text:
|
|
862
|
-
await websocket.send_json({
|
|
863
|
-
"type": "error",
|
|
864
|
-
"message": "Message text is required.",
|
|
865
|
-
})
|
|
866
|
-
continue
|
|
867
|
-
|
|
868
|
-
outgoing_payload: dict[str, Any] = {
|
|
869
|
-
"type": "message",
|
|
870
|
-
"text": text[:1000],
|
|
871
|
-
"time": int(time.time()),
|
|
872
|
-
}
|
|
873
|
-
if payload is not None:
|
|
874
|
-
outgoing_payload["payload"] = payload
|
|
875
|
-
await manager.broadcast_json(outgoing_payload)
|
|
876
|
-
except WebSocketDisconnect:
|
|
877
|
-
return
|
|
878
|
-
finally:
|
|
879
|
-
manager.disconnect(websocket)
|
|
880
|
-
|
|
881
745
|
|
|
882
746
|
if cfg.websocket:
|
|
883
747
|
# Optional, feature-gated module: only generated when websocket is enabled
|
|
884
748
|
# in caspian.config.json, so suppress the static "module not found" check.
|
|
885
|
-
from src.lib.websocket.
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
websocket_connections,
|
|
749
|
+
from src.lib.websocket.sockets import ( # type: ignore[import-not-found]
|
|
750
|
+
SOCKET_PATH,
|
|
751
|
+
serve_named_socket,
|
|
889
752
|
)
|
|
890
753
|
|
|
891
|
-
#
|
|
892
|
-
#
|
|
893
|
-
#
|
|
894
|
-
#
|
|
895
|
-
#
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
await _run_websocket_channel(
|
|
903
|
-
websocket,
|
|
904
|
-
websocket_connections,
|
|
905
|
-
None,
|
|
906
|
-
"Private WebSocket connected.",
|
|
907
|
-
)
|
|
908
|
-
|
|
909
|
-
@app.websocket(PUBLIC_WEBSOCKET_PATH)
|
|
910
|
-
async def websocket_public_endpoint(websocket: WebSocket):
|
|
911
|
-
if await authorize_websocket(websocket, require_auth=False) is None:
|
|
912
|
-
return
|
|
913
|
-
|
|
914
|
-
await _run_websocket_channel(
|
|
915
|
-
websocket,
|
|
916
|
-
public_websocket_connections,
|
|
917
|
-
{"guest": True, "scope": "public"},
|
|
918
|
-
"Public WebSocket connected.",
|
|
919
|
-
)
|
|
754
|
+
# Named sockets: the server half of `pp.socket(...)`. One endpoint for
|
|
755
|
+
# every `@socket()` function; the function is named in the `name` query
|
|
756
|
+
# parameter and the arguments arrive as the connection's first frame.
|
|
757
|
+
# Auth policy is per socket -- `@socket(require_auth=True, allowed_roles=
|
|
758
|
+
# [...])` -- so there are no separate public/private channel endpoints.
|
|
759
|
+
# Origin check, auth delegation, and connection/message limits live in
|
|
760
|
+
# `serve_named_socket` so this stays a pure wiring point.
|
|
761
|
+
@app.websocket(SOCKET_PATH)
|
|
762
|
+
async def websocket_named_socket_endpoint(websocket: WebSocket):
|
|
763
|
+
await serve_named_socket(websocket)
|
|
920
764
|
|
|
921
765
|
# ====
|
|
922
766
|
# Route Registration
|
|
@@ -944,7 +788,6 @@ def load_route_module(file_path: str):
|
|
|
944
788
|
assert spec is not None and spec.loader is not None, f"Cannot load spec for {file_path}"
|
|
945
789
|
module = importlib.util.module_from_spec(spec)
|
|
946
790
|
spec.loader.exec_module(module)
|
|
947
|
-
setattr(module, 'render_page', render_page)
|
|
948
791
|
_route_module_cache[abs_path] = (mtime_ns, module)
|
|
949
792
|
_route_signature_cache.pop(abs_path, None)
|
|
950
793
|
return module
|
|
@@ -1057,8 +900,7 @@ def register_routes():
|
|
|
1057
900
|
idx = get_files_index()
|
|
1058
901
|
for route in idx.routes:
|
|
1059
902
|
base_path = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
|
|
1060
|
-
|
|
1061
|
-
full_path = f"{base_path}/{file_name}".replace('//', '/')
|
|
903
|
+
full_path = f"{base_path}/index.py".replace('//', '/')
|
|
1062
904
|
register_single_route(route.fastapi_rule, full_path)
|
|
1063
905
|
|
|
1064
906
|
|
|
@@ -1087,77 +929,74 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
1087
929
|
|
|
1088
930
|
page_content_source = file_path
|
|
1089
931
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
request, name, param)
|
|
1114
|
-
|
|
1115
|
-
if inspect.iscoroutinefunction(module.page):
|
|
1116
|
-
result = await module.page(*call_args, **call_kwargs)
|
|
1117
|
-
else:
|
|
1118
|
-
result = module.page(*call_args, **call_kwargs)
|
|
1119
|
-
|
|
1120
|
-
if isinstance(result, Response):
|
|
1121
|
-
return result
|
|
1122
|
-
|
|
1123
|
-
if inspect.isasyncgen(result) or inspect.isgenerator(result):
|
|
1124
|
-
return SSE(cast("AsyncGenerator | Generator", result))
|
|
1125
|
-
|
|
1126
|
-
cache_settings = getattr(module, 'cache_settings', None)
|
|
1127
|
-
if cache_settings:
|
|
1128
|
-
req_should_cache = cache_settings.enabled
|
|
1129
|
-
req_cache_ttl = cache_settings.ttl
|
|
1130
|
-
|
|
1131
|
-
if isinstance(result, tuple):
|
|
1132
|
-
page_content = result[0]
|
|
1133
|
-
content = str(page_content)
|
|
1134
|
-
page_content_source = getattr(
|
|
1135
|
-
page_content, 'source_path', file_path)
|
|
1136
|
-
if len(result) >= 2 and isinstance(result[1], dict):
|
|
1137
|
-
page_layout_props = result[1]
|
|
1138
|
-
else:
|
|
1139
|
-
content = str(result)
|
|
1140
|
-
page_content_source = getattr(result, 'source_path', file_path)
|
|
1141
|
-
|
|
1142
|
-
dynamic_meta = _runtime_metadata.get()
|
|
1143
|
-
static_meta = getattr(module, 'metadata', None)
|
|
1144
|
-
|
|
1145
|
-
def extract_meta(obj):
|
|
1146
|
-
d = {}
|
|
1147
|
-
if not obj:
|
|
1148
|
-
return d
|
|
1149
|
-
if obj.title:
|
|
1150
|
-
d['title'] = obj.title
|
|
1151
|
-
if obj.description:
|
|
1152
|
-
d['description'] = obj.description
|
|
1153
|
-
if obj.extra:
|
|
1154
|
-
d.update(obj.extra)
|
|
1155
|
-
return d
|
|
932
|
+
module = load_route_module(file_path)
|
|
933
|
+
if not hasattr(module, 'page'):
|
|
934
|
+
raise AttributeError(f"Missing 'def page():' in {file_path}")
|
|
935
|
+
|
|
936
|
+
sig = get_page_signature(file_path, module.page)
|
|
937
|
+
call_kwargs = {}
|
|
938
|
+
call_args = []
|
|
939
|
+
|
|
940
|
+
if kwargs:
|
|
941
|
+
call_args.append(kwargs)
|
|
942
|
+
if 'request' in sig.parameters:
|
|
943
|
+
call_kwargs['request'] = request
|
|
944
|
+
|
|
945
|
+
for name, param in sig.parameters.items():
|
|
946
|
+
if name in call_kwargs:
|
|
947
|
+
continue
|
|
948
|
+
if name in ("kwargs",):
|
|
949
|
+
continue
|
|
950
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
951
|
+
continue
|
|
952
|
+
if name in request.query_params:
|
|
953
|
+
call_kwargs[name] = _coerce_query_param(
|
|
954
|
+
request, name, param)
|
|
1156
955
|
|
|
1157
|
-
|
|
1158
|
-
|
|
956
|
+
if inspect.iscoroutinefunction(module.page):
|
|
957
|
+
result = await module.page(*call_args, **call_kwargs)
|
|
958
|
+
else:
|
|
959
|
+
result = module.page(*call_args, **call_kwargs)
|
|
960
|
+
|
|
961
|
+
if isinstance(result, Response):
|
|
962
|
+
return result
|
|
963
|
+
|
|
964
|
+
if inspect.isasyncgen(result) or inspect.isgenerator(result):
|
|
965
|
+
return SSE(cast("AsyncGenerator | Generator", result))
|
|
966
|
+
|
|
967
|
+
cache_settings = getattr(module, 'cache_settings', None)
|
|
968
|
+
if cache_settings:
|
|
969
|
+
req_should_cache = cache_settings.enabled
|
|
970
|
+
req_cache_ttl = cache_settings.ttl
|
|
971
|
+
|
|
972
|
+
if isinstance(result, tuple):
|
|
973
|
+
page_content = result[0]
|
|
974
|
+
content = str(page_content)
|
|
975
|
+
page_content_source = getattr(
|
|
976
|
+
page_content, 'source_path', file_path)
|
|
977
|
+
if len(result) >= 2 and isinstance(result[1], dict):
|
|
978
|
+
page_layout_props = result[1]
|
|
1159
979
|
else:
|
|
1160
|
-
content =
|
|
980
|
+
content = str(result)
|
|
981
|
+
page_content_source = getattr(result, 'source_path', file_path)
|
|
982
|
+
|
|
983
|
+
dynamic_meta = _runtime_metadata.get()
|
|
984
|
+
static_meta = getattr(module, 'metadata', None)
|
|
985
|
+
|
|
986
|
+
def extract_meta(obj):
|
|
987
|
+
d = {}
|
|
988
|
+
if not obj:
|
|
989
|
+
return d
|
|
990
|
+
if obj.title:
|
|
991
|
+
d['title'] = obj.title
|
|
992
|
+
if obj.description:
|
|
993
|
+
d['description'] = obj.description
|
|
994
|
+
if obj.extra:
|
|
995
|
+
d.update(obj.extra)
|
|
996
|
+
return d
|
|
997
|
+
|
|
998
|
+
page_metadata.update(extract_meta(static_meta))
|
|
999
|
+
page_metadata.update(extract_meta(dynamic_meta))
|
|
1161
1000
|
|
|
1162
1001
|
full_context = {**kwargs, "request": request, **page_layout_props}
|
|
1163
1002
|
|
|
@@ -1198,17 +1037,16 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
1198
1037
|
'.', '_').replace('[', '').replace(']', '').replace('(', '').replace(')', '')
|
|
1199
1038
|
|
|
1200
1039
|
route_methods = ['GET', 'POST']
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
route_methods = list(dict.fromkeys(normalized_methods))
|
|
1040
|
+
module = load_route_module(file_path)
|
|
1041
|
+
declared_route_methods = getattr(module, 'route_methods', None)
|
|
1042
|
+
if isinstance(declared_route_methods, (list, tuple)) and declared_route_methods:
|
|
1043
|
+
normalized_methods = [
|
|
1044
|
+
str(method).strip().upper()
|
|
1045
|
+
for method in declared_route_methods
|
|
1046
|
+
if str(method).strip()
|
|
1047
|
+
]
|
|
1048
|
+
if normalized_methods:
|
|
1049
|
+
route_methods = list(dict.fromkeys(normalized_methods))
|
|
1212
1050
|
|
|
1213
1051
|
app.add_api_route(url_pattern, make_handler,
|
|
1214
1052
|
methods=route_methods, name=endpoint)
|
|
@@ -1234,11 +1072,186 @@ def defer_component_roots(html_output: str) -> str:
|
|
|
1234
1072
|
if 'pp-component' not in html_output:
|
|
1235
1073
|
return html_output
|
|
1236
1074
|
|
|
1075
|
+
# Fast path: the render pipeline recorded the page subtree's exact
|
|
1076
|
+
# serialized bytes. That string is finished bs4-serializer output -- fully
|
|
1077
|
+
# normalized, so re-parsing it is pure cost -- and it is usually almost the
|
|
1078
|
+
# entire document. Mask it behind a token, run the (now tiny) parse over
|
|
1079
|
+
# the layout shell, and apply the same wrap/entity-protection transforms to
|
|
1080
|
+
# the region string-level. Every mismatch falls back to the full parse.
|
|
1081
|
+
if not _DEFER_FAST_DISABLED:
|
|
1082
|
+
region = _finalize_page_region.get()
|
|
1083
|
+
if (
|
|
1084
|
+
region
|
|
1085
|
+
and len(region) >= _DEFER_REGION_MIN_BYTES
|
|
1086
|
+
and html_output.count(region) == 1
|
|
1087
|
+
):
|
|
1088
|
+
deferred = _defer_with_verbatim_region(html_output, region)
|
|
1089
|
+
if deferred is not None:
|
|
1090
|
+
return deferred
|
|
1091
|
+
|
|
1237
1092
|
masked_html, placeholders = mask_escaped_brace_entities(html_output)
|
|
1238
1093
|
soup = parse_fragment(masked_html)
|
|
1239
1094
|
return _defer_component_roots_in_soup(soup, placeholders, html_output)
|
|
1240
1095
|
|
|
1241
1096
|
|
|
1097
|
+
_DEFER_FAST_DISABLED = os.getenv(
|
|
1098
|
+
'CASP_DEFER_FAST', '').strip().lower() in {'0', 'off', 'false', 'no'}
|
|
1099
|
+
# Below this, masking the region saves less than the two extra scans it costs.
|
|
1100
|
+
_DEFER_REGION_MIN_BYTES = 4096
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _protect_region_brace_entities(value: str) -> str:
|
|
1104
|
+
"""String-level equivalent of the in-tree brace-entity protection.
|
|
1105
|
+
|
|
1106
|
+
Inside a deferred ``<template>``, each literal brace entity must gain one
|
|
1107
|
+
extra encoding layer (``{`` -> ``&#123;``) so the browser's parse
|
|
1108
|
+
of the response consumes the outer layer and PulsePoint still sees the
|
|
1109
|
+
entity. The tree path achieves this by restoring masked entities into the
|
|
1110
|
+
parsed template and letting the serializer escape the ``&``; on a verbatim
|
|
1111
|
+
region the same result is a direct substitution.
|
|
1112
|
+
"""
|
|
1113
|
+
from casp.html_native import _ESCAPED_BRACE_ENTITY_RE
|
|
1114
|
+
|
|
1115
|
+
return _ESCAPED_BRACE_ENTITY_RE.sub(
|
|
1116
|
+
lambda match: '&' + match.group(0)[1:], value)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
|
|
1120
|
+
"""Defer pass with the page subtree masked as an opaque token.
|
|
1121
|
+
|
|
1122
|
+
Returns ``None`` whenever the document does not match the shape this fast
|
|
1123
|
+
path understands, in which case the caller re-runs the full-parse pass.
|
|
1124
|
+
"""
|
|
1125
|
+
from casp.html_native import _PLACEHOLDER_COUNTER
|
|
1126
|
+
|
|
1127
|
+
# The region's own root boundary key, when its first tag carries one. The
|
|
1128
|
+
# region is serializer output, so '>' cannot appear inside an attribute
|
|
1129
|
+
# value and the first '>' reliably ends the opening tag. Edge whitespace is
|
|
1130
|
+
# common (a template authored as a triple-quoted string), so probe the
|
|
1131
|
+
# stripped core.
|
|
1132
|
+
region_root_key = None
|
|
1133
|
+
region_core = region.strip()
|
|
1134
|
+
open_tag_end = region_core.find('>')
|
|
1135
|
+
if region_core.startswith('<') and open_tag_end > 0:
|
|
1136
|
+
key_match = re.search(
|
|
1137
|
+
r'\spp-component="([^"]+)"', region_core[:open_tag_end + 1])
|
|
1138
|
+
if key_match:
|
|
1139
|
+
region_root_key = key_match.group(1)
|
|
1140
|
+
|
|
1141
|
+
token = f"__PP_DEFER_REGION_{next(_PLACEHOLDER_COUNTER)}__"
|
|
1142
|
+
masked_doc = html_output.replace(region, token, 1)
|
|
1143
|
+
|
|
1144
|
+
masked_html, placeholders = mask_escaped_brace_entities(masked_doc)
|
|
1145
|
+
soup = parse_fragment(masked_html)
|
|
1146
|
+
body = soup.body
|
|
1147
|
+
if body is None:
|
|
1148
|
+
return None
|
|
1149
|
+
|
|
1150
|
+
token_node = None
|
|
1151
|
+
for node in body.descendants:
|
|
1152
|
+
if isinstance(node, NavigableString) and token in node:
|
|
1153
|
+
token_node = node
|
|
1154
|
+
break
|
|
1155
|
+
if token_node is None:
|
|
1156
|
+
# The region did not land in the body (or the parse split the token);
|
|
1157
|
+
# nothing this path can reason about.
|
|
1158
|
+
return None
|
|
1159
|
+
|
|
1160
|
+
# Inside ANY boundary ancestor means the region ends up inside a deferred
|
|
1161
|
+
# template (the outermost one gets wrapped below, or already is one), so
|
|
1162
|
+
# its entities need the protection layer but no wrapper of its own.
|
|
1163
|
+
region_enclosed = any(
|
|
1164
|
+
isinstance(parent, Tag) and parent.has_attr('pp-component')
|
|
1165
|
+
for parent in token_node.parents
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
if not region_enclosed and region_root_key is None and 'pp-component' in region:
|
|
1169
|
+
# Boundaries live inside the region but its root is not one: they
|
|
1170
|
+
# would need wrapping at arbitrary depth, which only the tree pass can
|
|
1171
|
+
# locate.
|
|
1172
|
+
return None
|
|
1173
|
+
|
|
1174
|
+
roots = []
|
|
1175
|
+
stack = [
|
|
1176
|
+
child for child in reversed(body.contents) if isinstance(child, Tag)
|
|
1177
|
+
]
|
|
1178
|
+
while stack:
|
|
1179
|
+
el = stack.pop()
|
|
1180
|
+
if el.has_attr('pp-component'):
|
|
1181
|
+
if el.name != 'template':
|
|
1182
|
+
roots.append(el)
|
|
1183
|
+
continue
|
|
1184
|
+
stack.extend(
|
|
1185
|
+
child for child in reversed(el.contents) if isinstance(child, Tag)
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
for root in roots:
|
|
1189
|
+
key = root.get('pp-component')
|
|
1190
|
+
if key is None:
|
|
1191
|
+
continue
|
|
1192
|
+
template = soup.new_tag('template')
|
|
1193
|
+
template['pp-component'] = key
|
|
1194
|
+
root.insert_before(template)
|
|
1195
|
+
template.append(root.extract())
|
|
1196
|
+
|
|
1197
|
+
if placeholders:
|
|
1198
|
+
def protect_brace_entities(value: str) -> str:
|
|
1199
|
+
return _ESCAPED_BRACE_PLACEHOLDER_RE.sub(
|
|
1200
|
+
lambda match: placeholders.get(match.group(0), match.group(0)),
|
|
1201
|
+
value,
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
for template in body.select('template[pp-component]'):
|
|
1205
|
+
for node in list(template.descendants):
|
|
1206
|
+
if isinstance(node, NavigableString):
|
|
1207
|
+
original = str(node)
|
|
1208
|
+
if '__PP_ESCAPED_BRACE_' not in original:
|
|
1209
|
+
continue
|
|
1210
|
+
content = protect_brace_entities(original)
|
|
1211
|
+
if content != original:
|
|
1212
|
+
node.replace_with(content)
|
|
1213
|
+
elif isinstance(node, Tag):
|
|
1214
|
+
for name, value in node.attrs.items():
|
|
1215
|
+
if isinstance(value, str):
|
|
1216
|
+
if '__PP_ESCAPED_BRACE_' in value:
|
|
1217
|
+
node.attrs[name] = protect_brace_entities(
|
|
1218
|
+
value)
|
|
1219
|
+
elif isinstance(value, list):
|
|
1220
|
+
for index, item in enumerate(value):
|
|
1221
|
+
item = str(item)
|
|
1222
|
+
if '__PP_ESCAPED_BRACE_' in item:
|
|
1223
|
+
item = protect_brace_entities(item)
|
|
1224
|
+
value[index] = item
|
|
1225
|
+
|
|
1226
|
+
serialized = restore_escaped_brace_entities(
|
|
1227
|
+
serialize_fragment(soup), placeholders)
|
|
1228
|
+
if token not in serialized:
|
|
1229
|
+
return None
|
|
1230
|
+
|
|
1231
|
+
if region_enclosed:
|
|
1232
|
+
region_out = _protect_region_brace_entities(region)
|
|
1233
|
+
elif region_root_key is not None:
|
|
1234
|
+
# The tree path wraps only the root ELEMENT; whitespace at the region's
|
|
1235
|
+
# edges stays outside the template. Mirror that here. An edge comment
|
|
1236
|
+
# would be ambiguous to split off string-level, so leave that shape to
|
|
1237
|
+
# the full parse.
|
|
1238
|
+
core = region_core
|
|
1239
|
+
if not core.startswith('<') or core[1] in ('!', '?') or core.endswith('-->'):
|
|
1240
|
+
return None
|
|
1241
|
+
prefix_len = region.find('<')
|
|
1242
|
+
prefix = region[:prefix_len]
|
|
1243
|
+
suffix = region[prefix_len + len(core):]
|
|
1244
|
+
region_out = (
|
|
1245
|
+
f'{prefix}<template pp-component="{region_root_key}">'
|
|
1246
|
+
f'{_protect_region_brace_entities(core)}'
|
|
1247
|
+
f'</template>{suffix}'
|
|
1248
|
+
)
|
|
1249
|
+
else:
|
|
1250
|
+
region_out = region
|
|
1251
|
+
|
|
1252
|
+
return serialized.replace(token, region_out, 1)
|
|
1253
|
+
|
|
1254
|
+
|
|
1242
1255
|
def _defer_component_roots_in_soup(
|
|
1243
1256
|
soup,
|
|
1244
1257
|
placeholders,
|
|
@@ -1256,13 +1269,27 @@ def _defer_component_roots_in_soup(
|
|
|
1256
1269
|
if body is None:
|
|
1257
1270
|
return unchanged()
|
|
1258
1271
|
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1272
|
+
# Outermost boundaries only, found in one pruned walk. The previous
|
|
1273
|
+
# ``body.select('[pp-component]')`` matched every nested boundary and then
|
|
1274
|
+
# walked each match's full ancestor chain to discard it -- O(depth) per
|
|
1275
|
+
# boundary on documents whose boundary count is the whole point of the
|
|
1276
|
+
# page. Stopping the descent at the first boundary visits each outermost
|
|
1277
|
+
# subtree root exactly once and never enumerates the nested ones. An
|
|
1278
|
+
# element already inside a ``<template pp-component>`` stays untouched,
|
|
1279
|
+
# matching the ancestor-check semantics.
|
|
1280
|
+
roots = []
|
|
1281
|
+
stack = [
|
|
1282
|
+
child for child in reversed(body.contents) if isinstance(child, Tag)
|
|
1265
1283
|
]
|
|
1284
|
+
while stack:
|
|
1285
|
+
el = stack.pop()
|
|
1286
|
+
if el.has_attr('pp-component'):
|
|
1287
|
+
if el.name != 'template':
|
|
1288
|
+
roots.append(el)
|
|
1289
|
+
continue
|
|
1290
|
+
stack.extend(
|
|
1291
|
+
child for child in reversed(el.contents) if isinstance(child, Tag)
|
|
1292
|
+
)
|
|
1266
1293
|
if not roots:
|
|
1267
1294
|
return unchanged()
|
|
1268
1295
|
|
|
@@ -1286,26 +1313,36 @@ def _defer_component_roots_in_soup(
|
|
|
1286
1313
|
# Placeholders outside deferred component templates are restored normally
|
|
1287
1314
|
# after serialization.
|
|
1288
1315
|
if placeholders:
|
|
1316
|
+
# One compiled-regex pass per string instead of one full ``replace``
|
|
1317
|
+
# scan per placeholder, and nodes that carry no token (the vast
|
|
1318
|
+
# majority) are skipped by a C-level substring probe.
|
|
1289
1319
|
def protect_brace_entities(value: str) -> str:
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1320
|
+
return _ESCAPED_BRACE_PLACEHOLDER_RE.sub(
|
|
1321
|
+
lambda match: placeholders.get(match.group(0), match.group(0)),
|
|
1322
|
+
value,
|
|
1323
|
+
)
|
|
1294
1324
|
|
|
1295
1325
|
for template in body.select('template[pp-component]'):
|
|
1296
1326
|
for node in list(template.descendants):
|
|
1297
1327
|
if isinstance(node, NavigableString):
|
|
1298
1328
|
original = str(node)
|
|
1329
|
+
if '__PP_ESCAPED_BRACE_' not in original:
|
|
1330
|
+
continue
|
|
1299
1331
|
content = protect_brace_entities(original)
|
|
1300
1332
|
if content != original:
|
|
1301
1333
|
node.replace_with(content)
|
|
1302
1334
|
elif isinstance(node, Tag):
|
|
1303
1335
|
for name, value in node.attrs.items():
|
|
1304
1336
|
if isinstance(value, str):
|
|
1305
|
-
|
|
1337
|
+
if '__PP_ESCAPED_BRACE_' in value:
|
|
1338
|
+
node.attrs[name] = protect_brace_entities(
|
|
1339
|
+
value)
|
|
1306
1340
|
elif isinstance(value, list):
|
|
1307
1341
|
for index, item in enumerate(value):
|
|
1308
|
-
|
|
1342
|
+
item = str(item)
|
|
1343
|
+
if '__PP_ESCAPED_BRACE_' in item:
|
|
1344
|
+
item = protect_brace_entities(item)
|
|
1345
|
+
value[index] = item
|
|
1309
1346
|
|
|
1310
1347
|
return restore_escaped_brace_entities(serialize_fragment(soup), placeholders)
|
|
1311
1348
|
|