httk-web 0.1.0__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.
@@ -0,0 +1,694 @@
1
+ import posixpath
2
+ import re
3
+ from mimetypes import guess_type
4
+ from urllib.parse import SplitResult, urlsplit, urlunsplit
5
+
6
+ from markupsafe import Markup
7
+
8
+ from httk.web.engine.discovery import normalize_route, resolve_route
9
+ from httk.web.functions import PythonFunctionHandler
10
+ from httk.web.model.config import SiteConfig
11
+ from httk.web.model.errors import FunctionInjectionError, NotFoundError
12
+ from httk.web.model.page import PageResult, ResolvedRoute
13
+ from httk.web.model.request import HttpRequestContext
14
+ from httk.web.renderers import RENDERERS_BY_SUFFIX
15
+ from httk.web.templating import (
16
+ HttkCompatTemplateEngine,
17
+ JinjaTemplateEngine,
18
+ TemplateEngine,
19
+ TemplateRenderInput,
20
+ )
21
+
22
+
23
+ class SiteEngine:
24
+ def __init__(self, config: SiteConfig) -> None:
25
+ self.config = config
26
+ self.template_engine: TemplateEngine
27
+ if config.compatibility_mode:
28
+ self.template_engine = HttkCompatTemplateEngine(template_dir=config.template_dir)
29
+ else:
30
+ self.template_engine = JinjaTemplateEngine(template_dir=config.template_dir)
31
+ self.function_handler = PythonFunctionHandler(functions_dir=config.functions_dir)
32
+ self.global_data: dict[str, object] = self._load_global_config_metadata()
33
+ self._publish_route_mode_cache: dict[str, str] = {}
34
+ self._run_init_function()
35
+
36
+ def resolve(self, route: str) -> ResolvedRoute:
37
+ return resolve_route(config=self.config, route=route)
38
+
39
+ def render(
40
+ self,
41
+ route: str,
42
+ query: dict[str, str] | None = None,
43
+ request: HttpRequestContext | None = None,
44
+ ) -> PageResult:
45
+ resolved = self.resolve(route)
46
+
47
+ if resolved.kind == "missing" or resolved.source_path is None:
48
+ raise NotFoundError(f"Route not found: {resolved.route}")
49
+
50
+ if resolved.kind == "static":
51
+ content_type = guess_type(str(resolved.source_path))[0] or "application/octet-stream"
52
+ return PageResult(status_code=200, content_type=content_type, body=resolved.source_path.read_bytes())
53
+
54
+ if request is None:
55
+ request_context = HttpRequestContext(query=dict(query or {}))
56
+ else:
57
+ request_context = request
58
+ if query is not None:
59
+ request_context = HttpRequestContext(
60
+ method=request.method,
61
+ query=dict(query),
62
+ postvars=request.postvars,
63
+ headers=request.headers,
64
+ )
65
+
66
+ query_params = dict(request_context.query)
67
+ postvars = dict(request_context.postvars)
68
+ request_params = dict(query_params)
69
+ request_params.update(postvars)
70
+ rendered_html, metadata = self._render_content_without_templates(resolved)
71
+ route_key = normalize_route(route)
72
+ self._publish_route_mode_cache[route_key] = self._publish_route_mode_from_metadata(metadata)
73
+ warnings: list[str] = []
74
+
75
+ render_mode = "serve" if request is not None else "publish"
76
+ template_name = self._metadata_string(
77
+ metadata,
78
+ f"template_{render_mode}",
79
+ default=self._metadata_string(metadata, "template", default="default"),
80
+ )
81
+ base_template_name = self._metadata_string(
82
+ metadata,
83
+ f"base_template_{render_mode}",
84
+ default=self._metadata_string(metadata, "base_template", default="base_default"),
85
+ )
86
+
87
+ context = self._build_template_context(
88
+ route_key=route_key,
89
+ metadata=metadata,
90
+ query=query_params,
91
+ postvars=postvars,
92
+ request=request_context,
93
+ render_mode=render_mode,
94
+ )
95
+ self._apply_function_injections(
96
+ metadata=metadata,
97
+ context=context,
98
+ params=request_params,
99
+ route_key=route_key,
100
+ warnings=warnings,
101
+ )
102
+
103
+ content_html = self.template_engine.render(
104
+ TemplateRenderInput(
105
+ content_html=rendered_html,
106
+ template_name=template_name,
107
+ base_template_name=base_template_name,
108
+ context=context,
109
+ )
110
+ )
111
+ if render_mode == "publish":
112
+ content_html = self._rewrite_publish_links(content_html, route_key=route_key)
113
+
114
+ return PageResult(
115
+ status_code=200,
116
+ content_type="text/html; charset=utf-8",
117
+ body=content_html.encode("utf-8"),
118
+ metadata=metadata,
119
+ warnings=warnings,
120
+ )
121
+
122
+ def _render_content_without_templates(self, resolved: ResolvedRoute) -> tuple[str, dict[str, object]]:
123
+ if resolved.kind != "content" or resolved.source_path is None:
124
+ raise NotFoundError(f"Route is not content: {resolved.route}")
125
+
126
+ suffix = resolved.source_path.suffix.lower()
127
+ renderer = RENDERERS_BY_SUFFIX.get(suffix)
128
+ if renderer is None:
129
+ raise NotFoundError(f"No renderer for content suffix: {suffix}")
130
+
131
+ rendered = renderer.render(resolved.source_path)
132
+ return rendered.html, dict(rendered.metadata)
133
+
134
+ def _build_template_context(
135
+ self,
136
+ *,
137
+ route_key: str,
138
+ metadata: dict[str, object],
139
+ query: dict[str, str],
140
+ postvars: dict[str, str],
141
+ request: HttpRequestContext,
142
+ render_mode: str,
143
+ ) -> dict[str, object]:
144
+ context: dict[str, object] = dict(self.global_data)
145
+ context.update(metadata)
146
+ page_cache: dict[str, tuple[str, dict[str, object]]] = {}
147
+
148
+ def first_value(*values: object) -> object:
149
+ for value in values:
150
+ if value:
151
+ return value
152
+ if values:
153
+ return values[-1]
154
+ return None
155
+
156
+ def listdir(path: str, filters: str = "", limit: int | None = None) -> list[str]:
157
+ content_root = self.config.content_dir
158
+ target = (content_root / path).resolve()
159
+ try:
160
+ target.relative_to(content_root.resolve())
161
+ except ValueError:
162
+ return []
163
+
164
+ if not target.exists() or not target.is_dir():
165
+ return []
166
+
167
+ suffixes = [x.strip() for x in filters.split(";") if x.strip()]
168
+ files: list[str] = []
169
+ for child in sorted(target.iterdir()):
170
+ if not child.is_file():
171
+ continue
172
+ rel = str(child.relative_to(content_root)).replace("\\", "/")
173
+ if suffixes and not any(rel.endswith(suffix) for suffix in suffixes):
174
+ continue
175
+ files.append(rel)
176
+
177
+ if limit is not None:
178
+ return files[:limit]
179
+ return files
180
+
181
+ def pages(path: str, field: str) -> object:
182
+ normalized = normalize_route(path)
183
+
184
+ cached = page_cache.get(normalized)
185
+ if cached is not None:
186
+ page_html, page_metadata = cached
187
+ else:
188
+ target = self.resolve(path)
189
+ if target.kind != "content" or target.source_path is None:
190
+ return None
191
+ page_html, page_metadata = self._render_content_without_templates(target)
192
+ page_cache[normalized] = (page_html, page_metadata)
193
+
194
+ metadata_value = self._metadata_field_value(page_metadata, field)
195
+ if metadata_value is not None:
196
+ return metadata_value
197
+ if field in {"content", "html"}:
198
+ return page_html
199
+ if field == "relurl":
200
+ return self._route_link_url(
201
+ source_route_key=route_key,
202
+ target_route_key=normalized,
203
+ render_mode=render_mode,
204
+ relative_start=False,
205
+ )
206
+ return None
207
+
208
+ context["first_value"] = first_value
209
+ context["listdir"] = listdir
210
+ context["pages"] = pages
211
+ context["query"] = dict(query)
212
+ context["postvars"] = dict(postvars)
213
+ context["request"] = request
214
+ page_data = {
215
+ key: value
216
+ for key, value in metadata.items()
217
+ if isinstance(key, str) and key and not key.startswith("_") and not key.lower().endswith("-function")
218
+ }
219
+ page_data.update(
220
+ {
221
+ "relurl": self._route_link_url(
222
+ source_route_key=route_key,
223
+ target_route_key=route_key,
224
+ render_mode=render_mode,
225
+ relative_start=False,
226
+ ),
227
+ "absurl": self._absolute_url(route_key, render_mode=render_mode),
228
+ "relbaseurl": self._relative_base(route_key),
229
+ "functionurl": self._function_url(route_key, render_mode=render_mode),
230
+ }
231
+ )
232
+ context["page"] = page_data
233
+ return context
234
+
235
+ def _apply_function_injections(
236
+ self,
237
+ *,
238
+ metadata: dict[str, object],
239
+ context: dict[str, object],
240
+ params: dict[str, str],
241
+ route_key: str,
242
+ warnings: list[str],
243
+ ) -> None:
244
+ function_keys = [key for key in metadata if isinstance(key, str) and key.lower().endswith("-function")]
245
+
246
+ for function_key in function_keys:
247
+ try:
248
+ raw_spec = metadata.get(function_key)
249
+ if not isinstance(raw_spec, str):
250
+ del metadata[function_key]
251
+ continue
252
+
253
+ output_name = function_key[: -len("-function")]
254
+ function_name, arg_specs, function_template = self._parse_function_spec(raw_spec)
255
+ required = [x.strip() for x in arg_specs.split(",") if x.strip()]
256
+
257
+ if not self._function_args_satisfied(required, params):
258
+ context[output_name] = ""
259
+ metadata[output_name] = ""
260
+ warnings.append(
261
+ f"Function '{function_name}' on route '{route_key}' skipped: input parameter constraints not satisfied."
262
+ )
263
+ del metadata[function_key]
264
+ continue
265
+
266
+ result = self.function_handler.execute(function_name=function_name, params=params, global_data=context)
267
+ joint_context = dict(context)
268
+ joint_context["result"] = result
269
+
270
+ fragment = self.template_engine.render_fragment(template_name=function_template, context=joint_context)
271
+ if fragment is None:
272
+ output = str(result)
273
+ warnings.append(
274
+ f"Function '{function_name}' on route '{route_key}' rendered without template '{function_template}'."
275
+ )
276
+ else:
277
+ output = Markup(fragment)
278
+
279
+ context[output_name] = output
280
+ metadata[output_name] = output
281
+ del metadata[function_key]
282
+ except Exception as exc:
283
+ raise FunctionInjectionError(f"Failed processing function metadata '{function_key}': {exc}") from exc
284
+
285
+ def _parse_function_spec(self, spec: str) -> tuple[str, str, str]:
286
+ parts = spec.split(":", 2)
287
+ if len(parts) != 3:
288
+ raise ValueError(f"Invalid function spec: {spec}")
289
+
290
+ function_name = parts[0].strip()
291
+ function_args = parts[1].strip()
292
+ function_template = parts[2].strip()
293
+ if not function_name:
294
+ raise ValueError(f"Invalid function name in spec: {spec}")
295
+ return function_name, function_args, function_template
296
+
297
+ def _function_args_satisfied(self, arg_specs: list[str], query: dict[str, str]) -> bool:
298
+ for arg_spec in arg_specs:
299
+ if arg_spec.startswith("?"):
300
+ continue
301
+ if arg_spec.startswith("!"):
302
+ if arg_spec[1:] in query:
303
+ return False
304
+ continue
305
+ if arg_spec not in query:
306
+ return False
307
+ return True
308
+
309
+ def _absolute_url(self, route_key: str, *, render_mode: str) -> str:
310
+ route_path = self._route_url_path(route_key, render_mode=render_mode)
311
+ route_host = self._route_host(route_key, render_mode=render_mode)
312
+ if route_host is None:
313
+ return route_path
314
+ return self._join_host_path(route_host, route_path)
315
+
316
+ def _function_url(self, route_key: str, *, render_mode: str) -> str:
317
+ route_path = self._route_url_path(route_key, render_mode=render_mode)
318
+ if render_mode != "publish" or self.config.host_static is None:
319
+ return self._absolute_url(route_key, render_mode=render_mode)
320
+ if self.config.baseurl is None:
321
+ return route_path
322
+ return self._join_host_path(self.config.baseurl, route_path)
323
+
324
+ def _route_link_url(
325
+ self,
326
+ *,
327
+ source_route_key: str,
328
+ target_route_key: str,
329
+ render_mode: str,
330
+ relative_start: bool,
331
+ ) -> str:
332
+ target_path = self._route_url_path(target_route_key, render_mode=render_mode)
333
+ if render_mode != "publish" or self.config.host_static is None:
334
+ if relative_start:
335
+ return self._format_rewritten_path(target_path, route_key=source_route_key, target_path=target_path)
336
+ return target_path
337
+
338
+ source_host = self._route_host(source_route_key, render_mode=render_mode)
339
+ target_host = self._route_host(target_route_key, render_mode=render_mode)
340
+ if (
341
+ source_host is not None
342
+ and target_host is not None
343
+ and self._host_key(source_host) != self._host_key(target_host)
344
+ ):
345
+ return self._join_host_path(target_host, target_path)
346
+
347
+ if relative_start:
348
+ return self._format_rewritten_path(target_path, route_key=source_route_key, target_path=target_path)
349
+ return target_path
350
+
351
+ def _route_host(self, route_key: str, *, render_mode: str) -> str | None:
352
+ if render_mode != "publish":
353
+ return self.config.baseurl
354
+ if self.config.baseurl is None:
355
+ return None
356
+ if self.config.host_static is None:
357
+ return self.config.baseurl
358
+ route_mode = self._publish_route_mode(route_key)
359
+ if route_mode == "static":
360
+ return self.config.host_static
361
+ return self.config.baseurl
362
+
363
+ def _host_key(self, host: str) -> str:
364
+ return host.rstrip("/")
365
+
366
+ def _join_host_path(self, host: str, path: str) -> str:
367
+ base = host if host.endswith("/") else f"{host}/"
368
+ return f"{base}{path}"
369
+
370
+ def _relative_base(self, route_key: str) -> str:
371
+ depth = max(0, route_key.count("/"))
372
+ if depth == 0:
373
+ return "."
374
+ return "/".join(".." for _ in range(depth))
375
+
376
+ def _metadata_string(self, metadata: dict[str, object], key: str, *, default: str | None = None) -> str | None:
377
+ value = metadata.get(key)
378
+ if value is None and key:
379
+ title_case_key = f"{key[0].upper()}{key[1:]}"
380
+ value = metadata.get(title_case_key)
381
+
382
+ if value is None:
383
+ return default
384
+ if isinstance(value, str):
385
+ stripped = value.strip()
386
+ return stripped if stripped else default
387
+ return default
388
+
389
+ def _load_global_config_metadata(self) -> dict[str, object]:
390
+ config_name = self.config.config_name.strip()
391
+ if not config_name:
392
+ return {}
393
+
394
+ for suffix, renderer in RENDERERS_BY_SUFFIX.items():
395
+ candidate = self.config.srcdir / f"{config_name}{suffix}"
396
+ if not candidate.exists() or not candidate.is_file():
397
+ continue
398
+ rendered = renderer.render(candidate)
399
+ metadata = dict(rendered.metadata)
400
+ return self._normalize_legacy_list_keys(metadata)
401
+
402
+ return {}
403
+
404
+ def _run_init_function(self) -> None:
405
+ init_file = self.config.functions_dir / "init.py"
406
+ if not init_file.exists() or not init_file.is_file():
407
+ return
408
+
409
+ init_context = dict(self.global_data)
410
+ init_context["pages"] = self._global_pages_helper
411
+ self.function_handler.execute(function_name="init", params={}, global_data=init_context)
412
+ self.global_data.update(init_context)
413
+
414
+ def _normalize_legacy_list_keys(self, metadata: dict[str, object]) -> dict[str, object]:
415
+ normalized = dict(metadata)
416
+ for key, value in list(metadata.items()):
417
+ if not isinstance(key, str) or not key.endswith("-list"):
418
+ continue
419
+ base_key = key[: -len("-list")]
420
+ if isinstance(value, list):
421
+ normalized[base_key] = value
422
+ elif isinstance(value, str):
423
+ normalized[base_key] = [x.strip() for x in value.split(",") if x.strip()]
424
+ elif value is None:
425
+ normalized[base_key] = []
426
+ else:
427
+ normalized[base_key] = [value]
428
+ return normalized
429
+
430
+ def _global_pages_helper(self, path: str, field: str) -> object:
431
+ target = self.resolve(path)
432
+ if target.kind != "content" or target.source_path is None:
433
+ return None
434
+
435
+ page_html, page_metadata = self._render_content_without_templates(target)
436
+ metadata_value = self._metadata_field_value(page_metadata, field)
437
+ if metadata_value is not None:
438
+ return metadata_value
439
+ if field in {"content", "html"}:
440
+ return page_html
441
+ if field == "relurl":
442
+ route_key = normalize_route(path)
443
+ return self._route_link_url(
444
+ source_route_key=route_key,
445
+ target_route_key=route_key,
446
+ render_mode="publish",
447
+ relative_start=False,
448
+ )
449
+ return None
450
+
451
+ def _metadata_field_value(self, metadata: dict[str, object], field: str) -> object:
452
+ if field in metadata:
453
+ return metadata[field]
454
+ for key, value in metadata.items():
455
+ if isinstance(key, str) and key.lower() == field.lower():
456
+ return value
457
+ return None
458
+
459
+ def _publish_route_mode_from_metadata(self, metadata: dict[str, object]) -> str:
460
+ override = self._publish_route_mode_override(metadata)
461
+ if override is not None:
462
+ return override
463
+ has_functions = any(isinstance(key, str) and key.lower().endswith("-function") for key in metadata)
464
+ return "dynamic" if has_functions else "static"
465
+
466
+ def _publish_route_mode_override(self, metadata: dict[str, object]) -> str | None:
467
+ override_keys = {"publish_mode", "publish-mode", "hosting", "host"}
468
+ for key, value in metadata.items():
469
+ if not isinstance(key, str) or key.lower() not in override_keys:
470
+ continue
471
+ if not isinstance(value, str):
472
+ continue
473
+ normalized = value.strip().lower()
474
+ if normalized in {"static", "dynamic"}:
475
+ return normalized
476
+ return None
477
+
478
+ def _publish_route_mode(self, route_key: str) -> str:
479
+ cached = self._publish_route_mode_cache.get(route_key)
480
+ if cached is not None:
481
+ return cached
482
+
483
+ resolved = self.resolve(route_key)
484
+ if resolved.kind != "content" or resolved.source_path is None:
485
+ self._publish_route_mode_cache[route_key] = "static"
486
+ return "static"
487
+ _, metadata = self._render_content_without_templates(resolved)
488
+ mode = self._publish_route_mode_from_metadata(metadata)
489
+ self._publish_route_mode_cache[route_key] = mode
490
+ return mode
491
+
492
+ def _route_url_path(self, route_key: str, *, render_mode: str) -> str:
493
+ if render_mode == "publish" and not self.config.publish_use_urls_without_ext:
494
+ if route_key.endswith(".html"):
495
+ return route_key
496
+ return f"{route_key}.html"
497
+ return route_key
498
+
499
+ _HTML_TAG_PATTERN = re.compile(r"""<(?:[^<>"']+|"[^"]*"|'[^']*')+>""")
500
+ _ASSET_EXTENSIONS = {
501
+ ".css",
502
+ ".js",
503
+ ".json",
504
+ ".txt",
505
+ ".xml",
506
+ ".png",
507
+ ".jpg",
508
+ ".jpeg",
509
+ ".gif",
510
+ ".svg",
511
+ ".ico",
512
+ ".webp",
513
+ ".avif",
514
+ ".pdf",
515
+ ".zip",
516
+ ".tar",
517
+ ".gz",
518
+ ".mp4",
519
+ ".webm",
520
+ ".mp3",
521
+ ".wav",
522
+ }
523
+
524
+ def _rewrite_publish_links(self, html: str, *, route_key: str) -> str:
525
+ if self.config.publish_use_urls_without_ext:
526
+ return html
527
+
528
+ route_exists_cache: dict[str, bool] = {}
529
+
530
+ def replace_tag(match: re.Match[str]) -> str:
531
+ tag = match.group(0)
532
+ # Keep declarations/comments/closing tags untouched.
533
+ if tag.startswith("</") or tag.startswith("<!--") or tag.startswith("<!"):
534
+ return tag
535
+ return self._rewrite_tag_urls(tag, route_key=route_key, route_exists_cache=route_exists_cache)
536
+
537
+ return self._HTML_TAG_PATTERN.sub(replace_tag, html)
538
+
539
+ def _rewrite_tag_urls(self, tag: str, *, route_key: str, route_exists_cache: dict[str, bool]) -> str:
540
+ n = len(tag)
541
+ if n < 3 or not tag.startswith("<") or not tag.endswith(">"):
542
+ return tag
543
+
544
+ i = 1
545
+ while i < n - 1 and tag[i].isspace():
546
+ i += 1
547
+
548
+ # Skip tag name.
549
+ while i < n - 1 and not tag[i].isspace() and tag[i] not in {"/", ">"}:
550
+ i += 1
551
+
552
+ parts: list[str] = []
553
+ last = 0
554
+ while i < n - 1:
555
+ while i < n - 1 and tag[i].isspace():
556
+ i += 1
557
+ if i >= n - 1 or tag[i] in {">", "/"}:
558
+ break
559
+
560
+ name_start = i
561
+ while i < n - 1 and not tag[i].isspace() and tag[i] not in {"=", ">", "/"}:
562
+ i += 1
563
+ if i == name_start:
564
+ i += 1
565
+ continue
566
+ attr_name = tag[name_start:i].lower()
567
+
568
+ while i < n - 1 and tag[i].isspace():
569
+ i += 1
570
+ if i >= n - 1 or tag[i] != "=":
571
+ continue
572
+ i += 1
573
+
574
+ while i < n - 1 and tag[i].isspace():
575
+ i += 1
576
+ if i >= n - 1:
577
+ break
578
+
579
+ if tag[i] in {"'", '"'}:
580
+ quote = tag[i]
581
+ value_start = i + 1
582
+ value_end = tag.find(quote, value_start)
583
+ if value_end < 0:
584
+ break
585
+ raw_value = tag[value_start:value_end]
586
+ if attr_name in {"href", "src"}:
587
+ rewritten_value = self._rewrite_internal_url(
588
+ raw_value, route_key=route_key, route_exists_cache=route_exists_cache
589
+ )
590
+ if rewritten_value is not None:
591
+ parts.append(tag[last:value_start])
592
+ parts.append(rewritten_value)
593
+ last = value_end
594
+ i = value_end + 1
595
+ continue
596
+
597
+ # Unquoted attribute value.
598
+ value_start = i
599
+ while i < n - 1 and not tag[i].isspace() and tag[i] != ">":
600
+ i += 1
601
+ value_end = i
602
+ raw_value = tag[value_start:value_end]
603
+ if attr_name in {"href", "src"}:
604
+ rewritten_value = self._rewrite_internal_url(
605
+ raw_value, route_key=route_key, route_exists_cache=route_exists_cache
606
+ )
607
+ if rewritten_value is not None:
608
+ parts.append(tag[last:value_start])
609
+ parts.append(rewritten_value)
610
+ last = value_end
611
+
612
+ if not parts:
613
+ return tag
614
+ parts.append(tag[last:])
615
+ return "".join(parts)
616
+
617
+ def _rewrite_internal_url(
618
+ self,
619
+ url: str,
620
+ *,
621
+ route_key: str,
622
+ route_exists_cache: dict[str, bool],
623
+ ) -> str | None:
624
+ if not url or url.startswith("#") or url.startswith("//"):
625
+ return None
626
+
627
+ parts = urlsplit(url)
628
+ if parts.scheme or parts.netloc:
629
+ return None
630
+
631
+ if not parts.path:
632
+ return None
633
+
634
+ # Keep clearly non-page assets untouched.
635
+ if parts.path.endswith("/"):
636
+ path_ext = ""
637
+ else:
638
+ path_ext = posixpath.splitext(parts.path)[1].lower()
639
+ if path_ext and path_ext not in {"", ".html"} and path_ext in self._ASSET_EXTENSIONS:
640
+ return None
641
+
642
+ candidate_route = self._candidate_route_from_link_path(parts.path, route_key=route_key)
643
+ if candidate_route is None:
644
+ return None
645
+
646
+ is_content_route = route_exists_cache.get(candidate_route)
647
+ if is_content_route is None:
648
+ resolved = self.resolve(candidate_route)
649
+ is_content_route = resolved.kind == "content" and resolved.source_path is not None
650
+ route_exists_cache[candidate_route] = is_content_route
651
+ if not is_content_route:
652
+ return None
653
+
654
+ rewritten_path = self._route_link_url(
655
+ source_route_key=route_key,
656
+ target_route_key=candidate_route,
657
+ render_mode="publish",
658
+ relative_start=not parts.path.startswith("/"),
659
+ )
660
+ rewritten = SplitResult(parts.scheme, parts.netloc, rewritten_path, parts.query, parts.fragment)
661
+ return urlunsplit(rewritten)
662
+
663
+ def _candidate_route_from_link_path(self, path: str, *, route_key: str) -> str | None:
664
+ if not path:
665
+ return None
666
+
667
+ current_dir = posixpath.dirname(route_key)
668
+ if path.startswith("/"):
669
+ joined = posixpath.normpath(path.lstrip("/"))
670
+ else:
671
+ base = current_dir if current_dir else "."
672
+ joined = posixpath.normpath(posixpath.join(base, path))
673
+
674
+ if joined.startswith("../"):
675
+ return None
676
+
677
+ joined_lower = joined.lower()
678
+ for suffix in sorted(RENDERERS_BY_SUFFIX.keys(), key=len, reverse=True):
679
+ if joined_lower.endswith(suffix):
680
+ joined = joined[: -len(suffix)]
681
+ break
682
+
683
+ return normalize_route(joined)
684
+
685
+ def _format_rewritten_path(self, original_path: str, *, route_key: str, target_path: str) -> str:
686
+ if original_path.startswith("/"):
687
+ return f"/{target_path}"
688
+
689
+ current_dir = posixpath.dirname(route_key)
690
+ start = current_dir if current_dir else "."
691
+ rel = posixpath.relpath(target_path, start=start)
692
+ if rel == ".":
693
+ return target_path
694
+ return rel
@@ -0,0 +1,3 @@
1
+ from .python_module import PythonFunctionHandler
2
+
3
+ __all__ = ["PythonFunctionHandler"]