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.
- httk/web/__init__.py +3 -0
- httk/web/api.py +71 -0
- httk/web/compat/__init__.py +1 -0
- httk/web/engine/__init__.py +3 -0
- httk/web/engine/discovery.py +51 -0
- httk/web/engine/site_engine.py +694 -0
- httk/web/functions/__init__.py +3 -0
- httk/web/functions/python_module.py +110 -0
- httk/web/model/__init__.py +15 -0
- httk/web/model/config.py +60 -0
- httk/web/model/errors.py +14 -0
- httk/web/model/page.py +25 -0
- httk/web/model/request.py +9 -0
- httk/web/publishing/__init__.py +3 -0
- httk/web/publishing/static.py +44 -0
- httk/web/py.typed +0 -0
- httk/web/renderers/__init__.py +22 -0
- httk/web/renderers/_frontmatter.py +51 -0
- httk/web/renderers/base.py +13 -0
- httk/web/renderers/html.py +10 -0
- httk/web/renderers/httkweb_compat.py +29 -0
- httk/web/renderers/markdown.py +20 -0
- httk/web/renderers/rst.py +76 -0
- httk/web/runtime/__init__.py +3 -0
- httk/web/runtime/asgi.py +101 -0
- httk/web/runtime/devserver.py +6 -0
- httk/web/templating/__init__.py +5 -0
- httk/web/templating/_legacy_formatter.py +187 -0
- httk/web/templating/base.py +16 -0
- httk/web/templating/httk_compat.py +66 -0
- httk/web/templating/jinja2_engine.py +149 -0
- httk_web-0.1.0.dist-info/METADATA +54 -0
- httk_web-0.1.0.dist-info/RECORD +36 -0
- httk_web-0.1.0.dist-info/WHEEL +5 -0
- httk_web-0.1.0.dist-info/licenses/LICENSE +661 -0
- httk_web-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|