create-caspian-app 1.6.4 → 1.8.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/dist/.github/copilot-instructions.md +1 -1
- package/dist/AGENTS.md +470 -461
- package/dist/app-gitignore +2 -0
- package/dist/index.js +1 -1
- package/dist/main.py +424 -64
- package/dist/public/js/pp-reactive-v2.min.js +49 -1
- package/dist/settings/build-static.py +32 -2
- package/dist/settings/check.py +78 -34
- package/dist/src/app/error.py +94 -95
- package/dist/src/app/layout.py +12 -16
- package/package.json +1 -1
|
@@ -46,6 +46,8 @@ sys.path.insert(0, str(ROOT))
|
|
|
46
46
|
# Keep the app in non-production mode: dev generates an ephemeral session secret,
|
|
47
47
|
# so the build does not require production secrets to be present.
|
|
48
48
|
os.environ.setdefault("APP_ENV", "development")
|
|
49
|
+
# Starting the app's lifespan for the export must not start the task scheduler.
|
|
50
|
+
os.environ["CASPIAN_STATIC_EXPORT"] = "1"
|
|
49
51
|
|
|
50
52
|
import httpx2 # noqa: E402 (the app's HTTP client; drives the ASGI app in-process)
|
|
51
53
|
|
|
@@ -96,8 +98,12 @@ def _fill_rule(fastapi_rule: str, params) -> str:
|
|
|
96
98
|
params = {names[0]: params} if names else {}
|
|
97
99
|
url = fastapi_rule
|
|
98
100
|
for key, value in params.items():
|
|
101
|
+
if value is None or value == "":
|
|
102
|
+
# An optional catch-all (`[[...slug]]`) exports its base URL.
|
|
103
|
+
url = url.replace(f"/{{{key}:path}}", "")
|
|
104
|
+
continue
|
|
99
105
|
url = url.replace(f"{{{key}:path}}", str(value)).replace(f"{{{key}}}", str(value))
|
|
100
|
-
return url
|
|
106
|
+
return url or "/"
|
|
101
107
|
|
|
102
108
|
|
|
103
109
|
def _out_path_for(url_path: str) -> Path:
|
|
@@ -242,6 +248,19 @@ def build() -> int:
|
|
|
242
248
|
|
|
243
249
|
await export_one(client, url)
|
|
244
250
|
|
|
251
|
+
# sitemap.py / robots.py -> static/sitemap.xml, static/robots.txt
|
|
252
|
+
for url in getattr(main, "SEO_ROUTES", []):
|
|
253
|
+
resp = await client.get(url)
|
|
254
|
+
if resp.status_code != 200:
|
|
255
|
+
skipped.append((url, f"returned HTTP {resp.status_code}"))
|
|
256
|
+
continue
|
|
257
|
+
out_file = OUT_DIR / url.lstrip("/")
|
|
258
|
+
out_file.write_text(resp.text, encoding="utf-8")
|
|
259
|
+
exported.append(url)
|
|
260
|
+
if not os.getenv("APP_BASE_URL"):
|
|
261
|
+
seo_warnings.append(url)
|
|
262
|
+
|
|
263
|
+
seo_warnings: list[str] = []
|
|
245
264
|
asyncio.run(render_all())
|
|
246
265
|
|
|
247
266
|
_copy_assets()
|
|
@@ -249,9 +268,20 @@ def build() -> int:
|
|
|
249
268
|
# ---- Report ----
|
|
250
269
|
print(f"\n{GREEN}Exported {len(exported)} page(s):{RESET}")
|
|
251
270
|
for url in exported:
|
|
252
|
-
|
|
271
|
+
target = (
|
|
272
|
+
OUT_DIR / url.lstrip("/")
|
|
273
|
+
if url in getattr(main, "SEO_ROUTES", [])
|
|
274
|
+
else _out_path_for(url)
|
|
275
|
+
)
|
|
276
|
+
rel = target.relative_to(ROOT)
|
|
253
277
|
print(f" {GREEN}OK{RESET} {url} -> {rel}")
|
|
254
278
|
|
|
279
|
+
if seo_warnings:
|
|
280
|
+
print(
|
|
281
|
+
f"\n{YELLOW}Note: APP_BASE_URL is not set{RESET}{DIM} -- {', '.join(seo_warnings)} "
|
|
282
|
+
"contain http://testserver URLs. Set APP_BASE_URL to the public origin and rebuild.{RESET}"
|
|
283
|
+
)
|
|
284
|
+
|
|
255
285
|
if rpc_warnings:
|
|
256
286
|
print(f"\n{YELLOW}Note: these pages appear to use pp.rpc()/server actions{RESET}")
|
|
257
287
|
print(f"{DIM} (they render, but those interactions are dead without the backend):{RESET}")
|
package/dist/settings/check.py
CHANGED
|
@@ -24,9 +24,9 @@ import argparse
|
|
|
24
24
|
import itertools
|
|
25
25
|
import json
|
|
26
26
|
import os
|
|
27
|
-
import re
|
|
28
27
|
import subprocess
|
|
29
28
|
import sys
|
|
29
|
+
import tempfile
|
|
30
30
|
import threading
|
|
31
31
|
import time
|
|
32
32
|
from dataclasses import dataclass, field
|
|
@@ -277,51 +277,95 @@ def run_node_tests() -> Result:
|
|
|
277
277
|
once per edit. A silent regression there is invisible from a green gate and
|
|
278
278
|
costs a restart storm on the next feature branch.
|
|
279
279
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
280
|
+
The tests are authored against Vitest (`vi.useFakeTimers`, `describe`/`it`
|
|
281
|
+
from `vitest`), so they must run under Vitest -- `node --test` cannot load
|
|
282
|
+
them and fails every file before a single assertion runs.
|
|
283
|
+
|
|
284
|
+
Invoked as `node node_modules/vitest/vitest.mjs`, not `npx vitest`: `npx`
|
|
285
|
+
resolves to a `.cmd` shim on Windows that `subprocess` cannot exec from a
|
|
286
|
+
list argv, while `node` is a real executable on every platform the gate
|
|
287
|
+
runs on. Failure locations come from Vitest's JSON reporter, written beside
|
|
288
|
+
the live default reporter, rather than from scraping human-readable output.
|
|
283
289
|
"""
|
|
284
290
|
tests = sorted(
|
|
285
|
-
|
|
291
|
+
p.relative_to(PROJECT_ROOT).as_posix() for p in PROJECT_ROOT.glob("settings/*.test.ts")
|
|
286
292
|
)
|
|
287
293
|
if not tests:
|
|
288
294
|
return Result("node", ok=True, note="no TypeScript tests found")
|
|
289
295
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
line=line_no,
|
|
311
|
-
column=column,
|
|
312
|
-
tool="node",
|
|
313
|
-
code="test",
|
|
314
|
-
message=name or "test failed",
|
|
315
|
-
)
|
|
316
|
-
)
|
|
317
|
-
pending = None
|
|
296
|
+
vitest = PROJECT_ROOT / "node_modules" / "vitest" / "vitest.mjs"
|
|
297
|
+
if not vitest.is_file():
|
|
298
|
+
return Result("node", ok=False, note="vitest is not installed; run `npm install`")
|
|
299
|
+
|
|
300
|
+
with tempfile.TemporaryDirectory(prefix="pp-vitest-") as tmp:
|
|
301
|
+
report_path = Path(tmp) / "report.json"
|
|
302
|
+
proc = _run_streamed(
|
|
303
|
+
[
|
|
304
|
+
"node",
|
|
305
|
+
str(vitest),
|
|
306
|
+
"run",
|
|
307
|
+
"--includeTaskLocation",
|
|
308
|
+
"--reporter=default",
|
|
309
|
+
"--reporter=json",
|
|
310
|
+
f"--outputFile.json={report_path}",
|
|
311
|
+
*tests,
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
ok = proc.returncode == 0
|
|
315
|
+
issues = _vitest_issues(report_path) if not ok else []
|
|
318
316
|
|
|
319
317
|
note = ""
|
|
320
318
|
if not ok and not issues:
|
|
321
|
-
note = "
|
|
319
|
+
note = "vitest failed"
|
|
322
320
|
return Result("node", ok=ok, issues=issues, note=note)
|
|
323
321
|
|
|
324
322
|
|
|
323
|
+
def _vitest_issues(report_path: Path) -> list[Issue]:
|
|
324
|
+
"""Turn Vitest's JSON report into one issue per failing test or file."""
|
|
325
|
+
try:
|
|
326
|
+
report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
327
|
+
except OSError, ValueError:
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
issues: list[Issue] = []
|
|
331
|
+
for file_result in report.get("testResults", []):
|
|
332
|
+
raw_path = str(file_result.get("name", ""))
|
|
333
|
+
try:
|
|
334
|
+
path = Path(raw_path).resolve().relative_to(PROJECT_ROOT).as_posix()
|
|
335
|
+
except ValueError:
|
|
336
|
+
path = raw_path.replace("\\", "/")
|
|
337
|
+
|
|
338
|
+
failed = [a for a in file_result.get("assertionResults", []) if a.get("status") == "failed"]
|
|
339
|
+
for assertion in failed:
|
|
340
|
+
location = assertion.get("location") or {}
|
|
341
|
+
issues.append(
|
|
342
|
+
Issue(
|
|
343
|
+
path=path,
|
|
344
|
+
line=int(location.get("line", 1)),
|
|
345
|
+
column=int(location.get("column", 1)),
|
|
346
|
+
tool="node",
|
|
347
|
+
code="test",
|
|
348
|
+
message=assertion.get("fullName") or "test failed",
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# A file that fails to load (bad import, syntax error) has no failing
|
|
353
|
+
# assertions, only a file-level message.
|
|
354
|
+
if not failed and file_result.get("status") == "failed":
|
|
355
|
+
message = str(file_result.get("message") or "test file failed").strip()
|
|
356
|
+
issues.append(
|
|
357
|
+
Issue(
|
|
358
|
+
path=path,
|
|
359
|
+
line=1,
|
|
360
|
+
column=1,
|
|
361
|
+
tool="node",
|
|
362
|
+
code="test",
|
|
363
|
+
message=message.splitlines()[0] if message else "test file failed",
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
return issues
|
|
367
|
+
|
|
368
|
+
|
|
325
369
|
def run_pytest() -> Result:
|
|
326
370
|
# `-o addopts=` drops the ini `-q` so `-v` can print one live line per test
|
|
327
371
|
# (the "which test is running" progress); `-rfE` keeps the `FAILED nodeid -
|
package/dist/src/app/error.py
CHANGED
|
@@ -1,95 +1,94 @@
|
|
|
1
|
-
from
|
|
2
|
-
from casp.
|
|
3
|
-
from casp.layout import Metadata
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
metadata = Metadata(
|
|
7
|
-
title="Application Error",
|
|
8
|
-
description="An unexpected error occurred.",
|
|
9
|
-
extra={"robots": "noindex, nofollow"},
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def page(
|
|
14
|
-
return html(r"""
|
|
15
|
-
<main class="container py-10">
|
|
16
|
-
<div class="mx-auto max-w-2xl rounded bg-white p-6 shadow-sm">
|
|
17
|
-
<div class="flex items-start gap-4">
|
|
18
|
-
<svg class="h-10 w-10 text-red-600"
|
|
19
|
-
viewBox="0 0 24 24"
|
|
20
|
-
fill="none"
|
|
21
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
22
|
-
aria-hidden="true">
|
|
23
|
-
<path d="M11.001 2a1 1 0 0 1 .998 0L21 7v9a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V7l8.001-5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
24
|
-
<path d="M12 8v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
25
|
-
<path d="M12 16h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
26
|
-
</svg>
|
|
27
|
-
|
|
28
|
-
<div class="flex-1">
|
|
29
|
-
<
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<div class="mt-4 flex flex-wrap gap-2">
|
|
37
|
-
<a href="/"
|
|
38
|
-
class="inline-block rounded border border-gray-200 bg-gray-100 px-3 py-1.5 text-sm text-gray-800 hover:bg-gray-200">
|
|
39
|
-
Home
|
|
40
|
-
</a>
|
|
41
|
-
<button class="inline-block rounded bg-blue-600 px-3 py-1.5 text-sm text-white"
|
|
42
|
-
onclick="window.location.reload()">Reload</button>
|
|
43
|
-
{% if
|
|
44
|
-
<button class="inline-block rounded border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-black"
|
|
45
|
-
onclick="setShowTrace(current => !current)">{showTrace ? "Hide details" : "Show details"}</button>
|
|
46
|
-
{% endif %}
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
</div>
|
|
50
|
-
|
|
51
|
-
{% if
|
|
52
|
-
<section class="mt-4" hidden="{!showTrace}">
|
|
53
|
-
<div class="mb-2 flex items-center justify-between">
|
|
54
|
-
<div class="text-xs text-gray-500">Error details (development only)</div>
|
|
55
|
-
<div class="flex gap-2">
|
|
56
|
-
<button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
|
|
57
|
-
onclick="copyTrace()">{traceCopied ? "Copied" : "Copy"}</button>
|
|
58
|
-
<button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
|
|
59
|
-
onclick="downloadTrace()">Download</button>
|
|
60
|
-
</div>
|
|
61
|
-
</div>
|
|
62
|
-
<pre pp-ref="{tracePre}"
|
|
63
|
-
class="overflow-auto rounded bg-gray-100 p-4 text-xs text-black">{{
|
|
64
|
-
</section>
|
|
65
|
-
{% endif %}
|
|
66
|
-
|
|
67
|
-
<script>
|
|
68
|
-
const [showTrace, setShowTrace] = pp.state(false);
|
|
69
|
-
const [traceCopied, setTraceCopied] = pp.state(false);
|
|
70
|
-
const tracePre = pp.ref(null);
|
|
71
|
-
|
|
72
|
-
async function copyTrace() {
|
|
73
|
-
if (!tracePre.current) return;
|
|
74
|
-
await navigator.clipboard.writeText(tracePre.current.innerText);
|
|
75
|
-
setTraceCopied(true);
|
|
76
|
-
setTimeout(() => setTraceCopied(false), 2000);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function downloadTrace() {
|
|
80
|
-
if (!tracePre.current) return;
|
|
81
|
-
const blob = new Blob([tracePre.current.innerText], { type: "text/plain" });
|
|
82
|
-
const url = URL.createObjectURL(blob);
|
|
83
|
-
const link = document.createElement("a");
|
|
84
|
-
link.href = url;
|
|
85
|
-
link.download = "error_trace.txt";
|
|
86
|
-
link.click();
|
|
87
|
-
URL.revokeObjectURL(url);
|
|
88
|
-
}
|
|
89
|
-
</script>
|
|
90
|
-
</div>
|
|
91
|
-
</main>
|
|
92
|
-
""",
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)
|
|
1
|
+
from casp.component_decorator import html
|
|
2
|
+
from casp.errors import ErrorInfo
|
|
3
|
+
from casp.layout import Metadata
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
metadata = Metadata(
|
|
7
|
+
title="Application Error",
|
|
8
|
+
description="An unexpected error occurred.",
|
|
9
|
+
extra={"robots": "noindex, nofollow"},
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def page(error: ErrorInfo):
|
|
14
|
+
return html(r"""
|
|
15
|
+
<main class="container py-10">
|
|
16
|
+
<div class="mx-auto max-w-2xl rounded bg-white p-6 shadow-sm">
|
|
17
|
+
<div class="flex items-start gap-4">
|
|
18
|
+
<svg class="h-10 w-10 text-red-600"
|
|
19
|
+
viewBox="0 0 24 24"
|
|
20
|
+
fill="none"
|
|
21
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
22
|
+
aria-hidden="true">
|
|
23
|
+
<path d="M11.001 2a1 1 0 0 1 .998 0L21 7v9a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V7l8.001-5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
24
|
+
<path d="M12 8v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
25
|
+
<path d="M12 16h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
26
|
+
</svg>
|
|
27
|
+
|
|
28
|
+
<div class="flex-1">
|
|
29
|
+
<p class="text-xs font-medium uppercase tracking-wide text-gray-500">{{ error.status }} · {{ error.reason }}</p>
|
|
30
|
+
<h1 class="mt-1 text-2xl font-semibold text-red-600">Something went wrong</h1>
|
|
31
|
+
<p class="mt-3 text-sm text-gray-700">{{ error.message }}</p>
|
|
32
|
+
{% if error.request_id %}
|
|
33
|
+
<p class="mt-2 text-xs text-gray-500">Reference: {{ error.request_id }}</p>
|
|
34
|
+
{% endif %}
|
|
35
|
+
|
|
36
|
+
<div class="mt-4 flex flex-wrap gap-2">
|
|
37
|
+
<a href="/"
|
|
38
|
+
class="inline-block rounded border border-gray-200 bg-gray-100 px-3 py-1.5 text-sm text-gray-800 hover:bg-gray-200">
|
|
39
|
+
Home
|
|
40
|
+
</a>
|
|
41
|
+
<button class="inline-block rounded bg-blue-600 px-3 py-1.5 text-sm text-white"
|
|
42
|
+
onclick="window.location.reload()">Reload</button>
|
|
43
|
+
{% if error.trace %}
|
|
44
|
+
<button class="inline-block rounded border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-black"
|
|
45
|
+
onclick="setShowTrace(current => !current)">{showTrace ? "Hide details" : "Show details"}</button>
|
|
46
|
+
{% endif %}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{% if error.trace %}
|
|
52
|
+
<section class="mt-4" hidden="{!showTrace}">
|
|
53
|
+
<div class="mb-2 flex items-center justify-between">
|
|
54
|
+
<div class="text-xs text-gray-500">Error details (development only)</div>
|
|
55
|
+
<div class="flex gap-2">
|
|
56
|
+
<button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
|
|
57
|
+
onclick="copyTrace()">{traceCopied ? "Copied" : "Copy"}</button>
|
|
58
|
+
<button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
|
|
59
|
+
onclick="downloadTrace()">Download</button>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<pre pp-ref="{tracePre}"
|
|
63
|
+
class="overflow-auto rounded bg-gray-100 p-4 text-xs text-black">{{ error.trace }}</pre>
|
|
64
|
+
</section>
|
|
65
|
+
{% endif %}
|
|
66
|
+
|
|
67
|
+
<script>
|
|
68
|
+
const [showTrace, setShowTrace] = pp.state(false);
|
|
69
|
+
const [traceCopied, setTraceCopied] = pp.state(false);
|
|
70
|
+
const tracePre = pp.ref(null);
|
|
71
|
+
|
|
72
|
+
async function copyTrace() {
|
|
73
|
+
if (!tracePre.current) return;
|
|
74
|
+
await navigator.clipboard.writeText(tracePre.current.innerText);
|
|
75
|
+
setTraceCopied(true);
|
|
76
|
+
setTimeout(() => setTraceCopied(false), 2000);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function downloadTrace() {
|
|
80
|
+
if (!tracePre.current) return;
|
|
81
|
+
const blob = new Blob([tracePre.current.innerText], { type: "text/plain" });
|
|
82
|
+
const url = URL.createObjectURL(blob);
|
|
83
|
+
const link = document.createElement("a");
|
|
84
|
+
link.href = url;
|
|
85
|
+
link.download = "error_trace.txt";
|
|
86
|
+
link.click();
|
|
87
|
+
URL.revokeObjectURL(url);
|
|
88
|
+
}
|
|
89
|
+
</script>
|
|
90
|
+
</div>
|
|
91
|
+
</main>
|
|
92
|
+
""",
|
|
93
|
+
error=error,
|
|
94
|
+
)
|
package/dist/src/app/layout.py
CHANGED
|
@@ -1,37 +1,33 @@
|
|
|
1
1
|
from casp.component_decorator import html
|
|
2
|
+
from casp.layout import Metadata
|
|
3
|
+
|
|
4
|
+
# Root defaults, inherited by every page unless overridden. A page title can be
|
|
5
|
+
# formatted for the whole app with `title_template="%s | My App"`.
|
|
6
|
+
metadata = Metadata(description="Powered by Caspian Framework")
|
|
2
7
|
|
|
3
8
|
|
|
4
9
|
def layout():
|
|
5
10
|
return html(r"""
|
|
6
11
|
<!DOCTYPE html>
|
|
7
|
-
<html lang="
|
|
12
|
+
<html lang="{{ current_locale() }}">
|
|
8
13
|
|
|
9
14
|
<head>
|
|
10
15
|
<meta charset="UTF-8" />
|
|
11
16
|
<meta name="viewport"
|
|
12
17
|
content="width=device-width, initial-scale=1.0" />
|
|
13
18
|
<title>{{ metadata.title | default("Caspian App") }}</title>
|
|
14
|
-
<meta name="description"
|
|
15
|
-
content="{{ metadata.description | default('Powered by Caspian Framework') }}" />
|
|
16
19
|
{#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
so `extra` silently did nothing. Open Graph is keyed by `property`, every
|
|
21
|
-
other vocabulary by `name`.
|
|
20
|
+
Description, canonical, robots, keywords, Open Graph, Twitter, and every
|
|
21
|
+
`Metadata(extra={...})` entry. Each tag carries `data-pp-meta` so SPA
|
|
22
|
+
navigation replaces the set for the next page.
|
|
22
23
|
#}
|
|
23
|
-
{
|
|
24
|
-
if meta_name not in ("title", "description") and meta_content %}
|
|
25
|
-
{% if meta_name.startswith("og:") %}
|
|
26
|
-
<meta property="{{ meta_name }}" content="{{ meta_content }}" />
|
|
27
|
-
{% else %}
|
|
28
|
-
<meta name="{{ meta_name }}" content="{{ meta_content }}" />
|
|
29
|
-
{% endif %}
|
|
30
|
-
{% endfor %}
|
|
24
|
+
{{ metadata_tags(metadata | default({})) }}
|
|
31
25
|
<link rel="icon"
|
|
32
26
|
href="/favicon.ico"
|
|
33
27
|
type="image/x-icon"
|
|
34
28
|
sizes="16x16" />
|
|
29
|
+
<link href="/css/styles.css" rel="stylesheet" />
|
|
30
|
+
<script type="module" src="/js/main.js"></script>
|
|
35
31
|
</head>
|
|
36
32
|
|
|
37
33
|
<body style="
|