create-workframe 0.1.5 → 0.1.7
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/.dockerignore +22 -22
- package/.gitignore +73 -73
- package/LICENSE +201 -201
- package/NOTICE +12 -12
- package/README.md +40 -40
- package/SECURITY.md +38 -38
- package/bin/create-workframe.js +2816 -2814
- package/bin/workframe.js +329 -329
- package/docs/workspace-instructions/WORKFRAME_DISCORD.md +20 -20
- package/docs/workspace-instructions/WORKFRAME_DOCUMENTS_AND_ARTIFACTS.md +20 -20
- package/docs/workspace-instructions/WORKFRAME_KANBAN.md +20 -20
- package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +21 -21
- package/docs/workspace-instructions/WORKFRAME_ROUTING.md +29 -29
- package/docs/workspace-instructions/WORKFRAME_TELEGRAM.md +19 -19
- package/package.json +64 -64
- package/profiles/README.md +15 -15
- package/profiles/architect/AGENTS.md +29 -29
- package/profiles/architect/SOUL.md +44 -44
- package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/designer/AGENTS.md +26 -26
- package/profiles/designer/SOUL.md +31 -31
- package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/dev/AGENTS.md +28 -28
- package/profiles/dev/SOUL.md +31 -31
- package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/docs/AGENTS.md +27 -27
- package/profiles/docs/SOUL.md +30 -30
- package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/research/AGENTS.md +26 -26
- package/profiles/research/SOUL.md +31 -31
- package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/visionary/AGENTS.md +25 -25
- package/profiles/visionary/SOUL.md +31 -31
- package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/workframe-agent/AGENTS.md +37 -37
- package/profiles/workframe-agent/SETUP.md +185 -185
- package/profiles/workframe-agent/SOUL.md +61 -61
- package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
- package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
- package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
- package/prompts/WORKFRAME_PROMPT_TEMPLATES.md +16 -16
- package/rules/.hermes.md +11 -11
- package/rules/AGENTS.md +22 -22
- package/rules/workspace-README.md +5 -5
- package/scripts/apply-update-workframe.sh +91 -21
- package/scripts/bundle-workframe-ui.mjs +83 -83
- package/scripts/create_workframe_scaffold.py +648 -648
- package/scripts/ensure-compose-host-paths.mjs +51 -51
- package/scripts/lib/install-identity.mjs +212 -212
- package/scripts/lib/workframe-registry.mjs +290 -290
- package/scripts/new-project.mjs +68 -68
- package/scripts/select_agent_pack.py +31 -31
- package/scripts/set-compose-public-url.mjs +92 -92
- package/scripts/sync-canonical-to-package.mjs +164 -164
- package/shared/WORKFRAME_AGENT_LIBRARY.md +31 -31
- package/shared/WORKFRAME_AGENT_OPERATIONS.md +29 -29
- package/shared/WORKFRAME_AGENT_PACKS.json +64 -64
- package/shared/WORKFRAME_AGENT_PACKS.yaml +20 -20
- package/shared/WORKFRAME_CHAT_PERMISSION_MODEL.md +20 -20
- package/shared/WORKFRAME_HANDOFF_SCHEMA.md +25 -25
- package/shared/WORKFRAME_SKILL_CURATION.md +27 -27
- package/workframe-api/Dockerfile +14 -14
- package/workframe-api/README.md +24 -24
- package/workframe-api/action_proxy.py +131 -131
- package/workframe-api/auth_rate_limit.py +49 -49
- package/workframe-api/credential_vault.py +445 -445
- package/workframe-api/data/avatar-catalog.json +41 -41
- package/workframe-api/data/logo-catalog.json +14 -14
- package/workframe-api/data/user-avatar-catalog.json +18 -18
- package/workframe-api/email_sender.py +220 -220
- package/workframe-api/google_auth.py +90 -90
- package/workframe-api/install_api.py +359 -359
- package/workframe-api/internal_proxy_auth.py +150 -150
- package/workframe-api/llm_proxy.py +280 -280
- package/workframe-api/oidc_jwt.py +108 -108
- package/workframe-api/package.json +12 -12
- package/workframe-api/platform_auth.py +194 -194
- package/workframe-api/profile_secret_policy.py +86 -86
- package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
- package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
- package/workframe-api/public/assets/index-DglUqFB_.js +8 -8
- package/workframe-api/public/index.html +12 -12
- package/workframe-api/requirements.txt +2 -2
- package/workframe-api/server.py +20295 -19866
- package/workframe-api/site_meta.py +271 -271
- package/workframe-api/stack_config.py +435 -427
- package/workframe-api/test_ensure_native_profile.py +29 -0
- package/workframe-api/test_oauth_llm.py +58 -0
- package/workframe-api/test_provider_model_surface.py +31 -0
- package/workframe-api/test_stack_config_install_smtp.py +51 -0
- package/workframe-api/time-bind-chat.py +99 -99
- package/workframe-api/turn_credentials.py +226 -226
- package/workframe-api/updates.py +506 -506
- package/workframe-api/vault_kek.py +159 -159
- package/workframe-api/zk_auth.py +633 -633
- package/workframe-supervisor/Dockerfile +11 -11
- package/workframe-supervisor/profile_secret_policy.py +76 -76
- package/workframe-supervisor/server.py +860 -860
- package/workframe-ui/docker/nginx.conf +85 -85
- package/workframe-ui/public/assets/{arc-CnriPN3G.js → arc-Cn-lt9fT.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CCMNXHod.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DYYB1Ic2.js → architectureDiagram-3BPJPVTR-qXoGCqfH.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bn-fFOyj.js → blockDiagram-GPEHLZMM-DoopFlWj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BPPDtAnD.js → c4Diagram-AAUBKEIU-BRa7hfzE.js} +1 -1
- package/workframe-ui/public/assets/channel-CtFV3sE3.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Bz2CJiC3.js → chunk-2J33WTMH-Cl33E2hh.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-B8aMeE1B.js → chunk-3OPIFGDE-BRywYZQO.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-bLFHQTuT.js → chunk-4BX2VUAB-CI3xzp-0.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-BRxhx-lG.js → chunk-55IACEB6-Bkf3Ye3F.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BLyOgEJ8.js → chunk-5ZQYHXKU-C5RN3hUT.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-DiuvBl7G.js → chunk-727SXJPM-BaY0yDwt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-BE1_aBdC.js → chunk-AQP2D5EJ-VMMBaDqd.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-BicGsmBs.js → chunk-BSJP7CBP-DlNZ1UV3.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-C3VTns3d.js → chunk-CSCIHK7Q-tT2HyVy0.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-DSiXY3bY.js → chunk-FMBD7UC4-CyACRaUT.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-Cvw5s3fR.js → chunk-KSCS5N6A-seK5WAZs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-jJifGdIQ.js → chunk-L5ZTLDWV-r5cyG5AH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-Maee1aqo.js → chunk-LZXEDZCA-SnbeVxsJ.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-C77N3VAM.js → chunk-ND2GUHAM-zbFgEXSJ.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-BY68lrPB.js → chunk-NZK2D7GU-UhZc8kOk.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-D_c4kWzh.js → chunk-O5CBEL6O-DHvu2iZ-.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-B1g7l1_B.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B_qfZ6QP.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-DlQgvqBp.js → chunk-XPW4576I-CCPNKbzG.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CJoeCNq6.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CJoeCNq6.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CHCOHDSd.js → cose-bilkent-S5V4N54A-D1CLe6Y0.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-CKPcSYwU.js → dagre-BM42HDAG-DYHcGVs_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-C6qe0jP-.js → diagram-2AECGRRQ-DVUkJ-II.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-DhQhrieU.js → diagram-5GNKFQAL-C9Y1Acc2.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Bqf6Iw1r.js → diagram-KO2AKTUF-Db3zez-n.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt1ms2D2.js → diagram-LMA3HP47-BaOR5sIr.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-C0Rrxe1i.js → diagram-OG6HWLK6-fLBzMY5W.js} +1 -1
- package/workframe-ui/public/assets/{dist-BLcLhTn6.js → dist-uZYeD3o1.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Cy1GbZVu.js → erDiagram-TEJ5UH35-BoCxDSyE.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bw910jho.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-DL3ALqDG.js → flowDiagram-I6XJVG4X-BnYa3CAj.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-CMpwBhXQ.js → ganttDiagram-6RSMTGT7-Dm_e_Kaa.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-C0mwHUXL.js → gitGraph-WXDBUCRP-CBAj8ntv.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-6gzhWaF0.js → gitGraphDiagram-PVQCEYII-D5auJjJh.js} +1 -1
- package/workframe-ui/public/assets/index-DHLQNAXd.js +129 -0
- package/workframe-ui/public/assets/index-auJ0bLMU.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-Bb_wZTxU.js → info-J43DQDTF-BNMADxE2.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-601evYFV.js → infoDiagram-5YYISTIA-C8c45T4J.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BYtF-wHm.js → ishikawaDiagram-YF4QCWOH-CyWuHB4e.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BMizyHzC.js → journeyDiagram-JHISSGLW-B43pCuDT.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CLLJsdnh.js → kanban-definition-UN3LZRKU-Dyn480BI.js} +1 -1
- package/workframe-ui/public/assets/{line-BFiSvu-q.js → line-CNtIbnmt.js} +1 -1
- package/workframe-ui/public/assets/{linear-D6HYZ18_.js → linear-pmSg_4uV.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Cid5yDBQ.js → mermaid-parser.core-BX4tnojE.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-DqIiju5Z.js → mermaid.core-BtpBva5o.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Bg7xJdsz.js → mindmap-definition-RKZ34NQL-DcMcB_69.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-1ebGt86D.js → packet-YPE3B663-KSzK9WlD.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DJ9wOBC_.js → pie-LRSECV5Y-DLOH6Mg8.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-BmK96Aus.js → pieDiagram-4H26LBE5-BhrX2aGk.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-ByUqtWPZ.js → quadrantDiagram-W4KKPZXB-B5dDjbTX.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-DN-Hkt96.js → radar-GUYGQ44K-BPdjqZiu.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ByU2r8gb.js → requirementDiagram-4Y6WPE33-21Dq5e0N.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-elyWa0GK.js → sankeyDiagram-5OEKKPKP-CiA99dbO.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-CzZwsAiK.js → sequenceDiagram-3UESZ5HK-Bwpui7_x.js} +1 -1
- package/workframe-ui/public/assets/{src-5vgsM_dl.js → src-B3Axsi1U.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-B0O5w5lJ.js → stateDiagram-AJRCARHV-C76qwtE8.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-Dn0CDWJD.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-oyqa5w5y.js → timeline-definition-PNZ67QCA-CufInOyk.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-MCV12-eP.js → treeView-BLDUP644-C4sxh0R-.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-DiA1P9n5.js → treemap-LRROVOQU-BjV72ANV.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CO_4x7OR.js → vennDiagram-CIIHVFJN-DgsdR0Rl.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-CmQNv6Xv.js → wardley-L42UT6IY-DZ279G_t.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CJxDkbIj.js → wardleyDiagram-YWT4CUSO-Bde25kvz.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-B02MxgzG.js → xychartDiagram-2RQKCTM6-B8VnS2Pb.js} +1 -1
- package/workframe-ui/public/favicon.svg +7 -7
- package/workframe-ui/public/icons.svg +24 -24
- package/workframe-ui/public/index.html +47 -47
- package/workframe-ui/public/manifest.webmanifest +18 -18
- package/workframe-ui/public/workframe-config.json +3 -3
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CFhr7xAe.js +0 -1
- package/workframe-ui/public/assets/channel-Dok_-T0o.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C3wwLrTa.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-GFFSg2kf.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DKbGfT5Z.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DKbGfT5Z.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-C_PrceHe.js +0 -1
- package/workframe-ui/public/assets/index-DyO6K0TD.css +0 -1
- package/workframe-ui/public/assets/index-yvpSUs8T.js +0 -133
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-RXZRaSgT.js +0 -1
|
@@ -1,271 +1,271 @@
|
|
|
1
|
-
"""Public site metadata — OG tags, PWA manifest, link previews."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any, Callable
|
|
8
|
-
from urllib.parse import urljoin
|
|
9
|
-
|
|
10
|
-
import stack_config
|
|
11
|
-
|
|
12
|
-
DEFAULT_TITLE = "Workframe"
|
|
13
|
-
DEFAULT_SHORT_NAME = "Workframe"
|
|
14
|
-
DEFAULT_DESCRIPTION = "Project chat and workspace for Hermes agent crews"
|
|
15
|
-
DEFAULT_THEME_COLOR = "#0A0A0F"
|
|
16
|
-
DEFAULT_OG_IMAGE = "/assets/branding/og-default.png"
|
|
17
|
-
DEFAULT_FAVICON = "/favicon.svg"
|
|
18
|
-
|
|
19
|
-
BRANDING_DIR_NAME = "site-branding"
|
|
20
|
-
OG_FILENAME = "og-image"
|
|
21
|
-
FAVICON_FILENAME = "favicon"
|
|
22
|
-
|
|
23
|
-
_MIME_BY_SUFFIX = {
|
|
24
|
-
".png": "image/png",
|
|
25
|
-
".jpg": "image/jpeg",
|
|
26
|
-
".jpeg": "image/jpeg",
|
|
27
|
-
".webp": "image/webp",
|
|
28
|
-
".svg": "image/svg+xml",
|
|
29
|
-
".ico": "image/x-icon",
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def branding_dir() -> Path:
|
|
34
|
-
root = stack_config.DATA_DIR / BRANDING_DIR_NAME
|
|
35
|
-
root.mkdir(parents=True, exist_ok=True)
|
|
36
|
-
return root
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _site_branding_raw() -> dict[str, Any]:
|
|
40
|
-
raw = stack_config._read_raw()
|
|
41
|
-
block = raw.get("site_branding")
|
|
42
|
-
return block if isinstance(block, dict) else {}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def site_branding_public() -> dict[str, Any]:
|
|
46
|
-
block = _site_branding_raw()
|
|
47
|
-
return {
|
|
48
|
-
"title": str(block.get("title") or "").strip(),
|
|
49
|
-
"description": str(block.get("description") or "").strip(),
|
|
50
|
-
"theme_color": str(block.get("theme_color") or "").strip(),
|
|
51
|
-
"has_og_image": _branding_asset_path("og").is_file(),
|
|
52
|
-
"has_favicon": _branding_asset_path("favicon").is_file(),
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def patch_site_branding(body: dict[str, Any]) -> None:
|
|
57
|
-
raw = stack_config._read_raw()
|
|
58
|
-
block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
|
|
59
|
-
for key in ("title", "description", "theme_color"):
|
|
60
|
-
if key in body:
|
|
61
|
-
block[key] = str(body.get(key) or "").strip()
|
|
62
|
-
raw["site_branding"] = block
|
|
63
|
-
stack_config._write_raw(raw)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _branding_asset_path(kind: str) -> Path:
|
|
67
|
-
block = _site_branding_raw()
|
|
68
|
-
rel = str(block.get(f"{kind}_file") or "").strip()
|
|
69
|
-
if rel:
|
|
70
|
-
candidate = stack_config.DATA_DIR / rel
|
|
71
|
-
if candidate.is_file():
|
|
72
|
-
return candidate
|
|
73
|
-
for suffix in (".png", ".jpg", ".jpeg", ".webp", ".svg", ".ico"):
|
|
74
|
-
candidate = branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}{suffix}"
|
|
75
|
-
if candidate.is_file():
|
|
76
|
-
return candidate
|
|
77
|
-
return branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}.png"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def save_branding_asset(kind: str, data: bytes, content_type: str = "") -> Path:
|
|
81
|
-
if kind not in {"og", "favicon"}:
|
|
82
|
-
raise ValueError("invalid_branding_kind")
|
|
83
|
-
if not data:
|
|
84
|
-
raise ValueError("empty_asset")
|
|
85
|
-
suffix = _suffix_for_upload(content_type, data)
|
|
86
|
-
dest = branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}{suffix}"
|
|
87
|
-
dest.write_bytes(data)
|
|
88
|
-
raw = stack_config._read_raw()
|
|
89
|
-
block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
|
|
90
|
-
block[f"{kind}_file"] = f"{BRANDING_DIR_NAME}/{dest.name}"
|
|
91
|
-
raw["site_branding"] = block
|
|
92
|
-
stack_config._write_raw(raw)
|
|
93
|
-
return dest
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _suffix_for_upload(content_type: str, data: bytes) -> str:
|
|
97
|
-
ctype = str(content_type or "").split(";")[0].strip().lower()
|
|
98
|
-
if ctype == "image/png" or data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
99
|
-
return ".png"
|
|
100
|
-
if ctype in {"image/jpeg", "image/jpg"} or data[:3] == b"\xff\xd8\xff":
|
|
101
|
-
return ".jpg"
|
|
102
|
-
if ctype == "image/webp" or data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
103
|
-
return ".webp"
|
|
104
|
-
if ctype == "image/svg+xml" or data.lstrip()[:5] == b"<svg " or b"<svg" in data[:200]:
|
|
105
|
-
return ".svg"
|
|
106
|
-
if ctype == "image/x-icon":
|
|
107
|
-
return ".ico"
|
|
108
|
-
return ".png"
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _abs_url(base: str, path: str) -> str:
|
|
112
|
-
base = str(base or "").strip().rstrip("/")
|
|
113
|
-
path = str(path or "").strip()
|
|
114
|
-
if not path:
|
|
115
|
-
return ""
|
|
116
|
-
if path.startswith("http://") or path.startswith("https://"):
|
|
117
|
-
return path
|
|
118
|
-
if not base:
|
|
119
|
-
return path
|
|
120
|
-
return urljoin(f"{base}/", path.lstrip("/"))
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _browser_asset_url(base: str, path: str) -> str:
|
|
124
|
-
"""Same-origin static assets — relative paths avoid loopback leaks on HTTPS deploys."""
|
|
125
|
-
path = str(path or "").strip()
|
|
126
|
-
if path.startswith("/") and not path.startswith("//"):
|
|
127
|
-
return path
|
|
128
|
-
return _abs_url(base, path)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def resolve_site_meta(
|
|
132
|
-
*,
|
|
133
|
-
app_base_url: str,
|
|
134
|
-
install_complete: bool,
|
|
135
|
-
workspace: dict[str, Any] | None = None,
|
|
136
|
-
normalize_logo: Callable[[str], str] | None = None,
|
|
137
|
-
) -> dict[str, Any]:
|
|
138
|
-
"""Merge stack overrides, primary workspace identity, and Workframe defaults."""
|
|
139
|
-
overrides = _site_branding_raw()
|
|
140
|
-
ws = workspace or {}
|
|
141
|
-
|
|
142
|
-
title = str(overrides.get("title") or "").strip()
|
|
143
|
-
if not title and install_complete:
|
|
144
|
-
title = str(ws.get("display_name") or "").strip()
|
|
145
|
-
if not title:
|
|
146
|
-
title = DEFAULT_TITLE
|
|
147
|
-
|
|
148
|
-
description = str(overrides.get("description") or "").strip()
|
|
149
|
-
if not description and install_complete:
|
|
150
|
-
description = str(ws.get("description") or "").strip()
|
|
151
|
-
if not description:
|
|
152
|
-
description = DEFAULT_DESCRIPTION
|
|
153
|
-
|
|
154
|
-
tagline = str(ws.get("tagline") or "").strip() if install_complete else ""
|
|
155
|
-
|
|
156
|
-
theme_color = str(overrides.get("theme_color") or "").strip() or DEFAULT_THEME_COLOR
|
|
157
|
-
|
|
158
|
-
og_path = _branding_asset_path("og")
|
|
159
|
-
if og_path.is_file():
|
|
160
|
-
og_image = _browser_asset_url(app_base_url, f"/api/public/branding/og{og_path.suffix}")
|
|
161
|
-
elif install_complete:
|
|
162
|
-
logo = str(ws.get("avatar_url") or "").strip()
|
|
163
|
-
if logo and normalize_logo:
|
|
164
|
-
logo = normalize_logo(logo)
|
|
165
|
-
og_image = _browser_asset_url(app_base_url, logo) if logo else _browser_asset_url(app_base_url, DEFAULT_OG_IMAGE)
|
|
166
|
-
else:
|
|
167
|
-
og_image = _browser_asset_url(app_base_url, DEFAULT_OG_IMAGE)
|
|
168
|
-
|
|
169
|
-
fav_path = _branding_asset_path("favicon")
|
|
170
|
-
if fav_path.is_file():
|
|
171
|
-
favicon = _browser_asset_url(app_base_url, f"/api/public/branding/favicon{fav_path.suffix}")
|
|
172
|
-
else:
|
|
173
|
-
favicon = _browser_asset_url(app_base_url, DEFAULT_FAVICON)
|
|
174
|
-
|
|
175
|
-
short_name = title if len(title) <= 16 else title[:15].rstrip() + "…"
|
|
176
|
-
canonical = app_base_url.rstrip("/") + "/" if app_base_url else "/"
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
"ok": True,
|
|
180
|
-
"install_complete": bool(install_complete),
|
|
181
|
-
"title": title,
|
|
182
|
-
"short_name": short_name,
|
|
183
|
-
"description": description,
|
|
184
|
-
"tagline": tagline,
|
|
185
|
-
"theme_color": theme_color,
|
|
186
|
-
"og_image": og_image,
|
|
187
|
-
"favicon": favicon,
|
|
188
|
-
"canonical_url": canonical,
|
|
189
|
-
"manifest_url": _browser_asset_url(app_base_url, "/manifest.webmanifest"),
|
|
190
|
-
"source": {
|
|
191
|
-
"title": "stack" if overrides.get("title") else ("workspace" if install_complete and ws.get("display_name") else "default"),
|
|
192
|
-
"description": "stack" if overrides.get("description") else ("workspace" if install_complete and ws.get("description") else "default"),
|
|
193
|
-
"og_image": "upload" if og_path.is_file() else ("workspace_logo" if install_complete and ws.get("avatar_url") else "default"),
|
|
194
|
-
"favicon": "upload" if fav_path.is_file() else "default",
|
|
195
|
-
},
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def manifest_payload(meta: dict[str, Any]) -> dict[str, Any]:
|
|
200
|
-
icons = []
|
|
201
|
-
favicon = str(meta.get("favicon") or "").strip()
|
|
202
|
-
if favicon:
|
|
203
|
-
icons.append(
|
|
204
|
-
{
|
|
205
|
-
"src": favicon,
|
|
206
|
-
"sizes": "any",
|
|
207
|
-
"type": "image/svg+xml" if favicon.endswith(".svg") else "image/png",
|
|
208
|
-
"purpose": "any",
|
|
209
|
-
},
|
|
210
|
-
)
|
|
211
|
-
return {
|
|
212
|
-
"name": meta.get("title") or DEFAULT_TITLE,
|
|
213
|
-
"short_name": meta.get("short_name") or DEFAULT_SHORT_NAME,
|
|
214
|
-
"description": meta.get("description") or DEFAULT_DESCRIPTION,
|
|
215
|
-
"start_url": "./",
|
|
216
|
-
"display": "standalone",
|
|
217
|
-
"background_color": meta.get("theme_color") or DEFAULT_THEME_COLOR,
|
|
218
|
-
"theme_color": meta.get("theme_color") or DEFAULT_THEME_COLOR,
|
|
219
|
-
"orientation": "any",
|
|
220
|
-
"icons": icons or [{"src": "./favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}],
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def link_preview_html(meta: dict[str, Any]) -> str:
|
|
225
|
-
title = _html_escape(str(meta.get("title") or DEFAULT_TITLE))
|
|
226
|
-
description = _html_escape(str(meta.get("description") or DEFAULT_DESCRIPTION))
|
|
227
|
-
canonical = _html_escape(str(meta.get("canonical_url") or "/"))
|
|
228
|
-
og_image = _html_escape(str(meta.get("og_image") or ""))
|
|
229
|
-
theme = _html_escape(str(meta.get("theme_color") or DEFAULT_THEME_COLOR))
|
|
230
|
-
favicon = _html_escape(str(meta.get("favicon") or DEFAULT_FAVICON))
|
|
231
|
-
return f"""<!doctype html>
|
|
232
|
-
<html lang="en">
|
|
233
|
-
<head>
|
|
234
|
-
<meta charset="utf-8" />
|
|
235
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
236
|
-
<title>{title}</title>
|
|
237
|
-
<meta name="description" content="{description}" />
|
|
238
|
-
<meta name="theme-color" content="{theme}" />
|
|
239
|
-
<link rel="icon" href="{favicon}" />
|
|
240
|
-
<meta property="og:type" content="website" />
|
|
241
|
-
<meta property="og:site_name" content="{title}" />
|
|
242
|
-
<meta property="og:title" content="{title}" />
|
|
243
|
-
<meta property="og:description" content="{description}" />
|
|
244
|
-
<meta property="og:url" content="{canonical}" />
|
|
245
|
-
<meta property="og:image" content="{og_image}" />
|
|
246
|
-
<meta name="twitter:card" content="summary_large_image" />
|
|
247
|
-
<meta name="twitter:title" content="{title}" />
|
|
248
|
-
<meta name="twitter:description" content="{description}" />
|
|
249
|
-
<meta name="twitter:image" content="{og_image}" />
|
|
250
|
-
<meta http-equiv="refresh" content="0;url={canonical}" />
|
|
251
|
-
</head>
|
|
252
|
-
<body><p><a href="{canonical}">{title}</a></p></body>
|
|
253
|
-
</html>
|
|
254
|
-
"""
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def branding_asset_bytes(kind: str) -> tuple[bytes, str] | None:
|
|
258
|
-
path = _branding_asset_path(kind)
|
|
259
|
-
if not path.is_file():
|
|
260
|
-
return None
|
|
261
|
-
ctype = _MIME_BY_SUFFIX.get(path.suffix.lower(), "application/octet-stream")
|
|
262
|
-
return path.read_bytes(), ctype
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def _html_escape(value: str) -> str:
|
|
266
|
-
return (
|
|
267
|
-
value.replace("&", "&")
|
|
268
|
-
.replace("<", "<")
|
|
269
|
-
.replace(">", ">")
|
|
270
|
-
.replace('"', """)
|
|
271
|
-
)
|
|
1
|
+
"""Public site metadata — OG tags, PWA manifest, link previews."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
|
|
10
|
+
import stack_config
|
|
11
|
+
|
|
12
|
+
DEFAULT_TITLE = "Workframe"
|
|
13
|
+
DEFAULT_SHORT_NAME = "Workframe"
|
|
14
|
+
DEFAULT_DESCRIPTION = "Project chat and workspace for Hermes agent crews"
|
|
15
|
+
DEFAULT_THEME_COLOR = "#0A0A0F"
|
|
16
|
+
DEFAULT_OG_IMAGE = "/assets/branding/og-default.png"
|
|
17
|
+
DEFAULT_FAVICON = "/favicon.svg"
|
|
18
|
+
|
|
19
|
+
BRANDING_DIR_NAME = "site-branding"
|
|
20
|
+
OG_FILENAME = "og-image"
|
|
21
|
+
FAVICON_FILENAME = "favicon"
|
|
22
|
+
|
|
23
|
+
_MIME_BY_SUFFIX = {
|
|
24
|
+
".png": "image/png",
|
|
25
|
+
".jpg": "image/jpeg",
|
|
26
|
+
".jpeg": "image/jpeg",
|
|
27
|
+
".webp": "image/webp",
|
|
28
|
+
".svg": "image/svg+xml",
|
|
29
|
+
".ico": "image/x-icon",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def branding_dir() -> Path:
|
|
34
|
+
root = stack_config.DATA_DIR / BRANDING_DIR_NAME
|
|
35
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
return root
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _site_branding_raw() -> dict[str, Any]:
|
|
40
|
+
raw = stack_config._read_raw()
|
|
41
|
+
block = raw.get("site_branding")
|
|
42
|
+
return block if isinstance(block, dict) else {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def site_branding_public() -> dict[str, Any]:
|
|
46
|
+
block = _site_branding_raw()
|
|
47
|
+
return {
|
|
48
|
+
"title": str(block.get("title") or "").strip(),
|
|
49
|
+
"description": str(block.get("description") or "").strip(),
|
|
50
|
+
"theme_color": str(block.get("theme_color") or "").strip(),
|
|
51
|
+
"has_og_image": _branding_asset_path("og").is_file(),
|
|
52
|
+
"has_favicon": _branding_asset_path("favicon").is_file(),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def patch_site_branding(body: dict[str, Any]) -> None:
|
|
57
|
+
raw = stack_config._read_raw()
|
|
58
|
+
block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
|
|
59
|
+
for key in ("title", "description", "theme_color"):
|
|
60
|
+
if key in body:
|
|
61
|
+
block[key] = str(body.get(key) or "").strip()
|
|
62
|
+
raw["site_branding"] = block
|
|
63
|
+
stack_config._write_raw(raw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _branding_asset_path(kind: str) -> Path:
|
|
67
|
+
block = _site_branding_raw()
|
|
68
|
+
rel = str(block.get(f"{kind}_file") or "").strip()
|
|
69
|
+
if rel:
|
|
70
|
+
candidate = stack_config.DATA_DIR / rel
|
|
71
|
+
if candidate.is_file():
|
|
72
|
+
return candidate
|
|
73
|
+
for suffix in (".png", ".jpg", ".jpeg", ".webp", ".svg", ".ico"):
|
|
74
|
+
candidate = branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}{suffix}"
|
|
75
|
+
if candidate.is_file():
|
|
76
|
+
return candidate
|
|
77
|
+
return branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}.png"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save_branding_asset(kind: str, data: bytes, content_type: str = "") -> Path:
|
|
81
|
+
if kind not in {"og", "favicon"}:
|
|
82
|
+
raise ValueError("invalid_branding_kind")
|
|
83
|
+
if not data:
|
|
84
|
+
raise ValueError("empty_asset")
|
|
85
|
+
suffix = _suffix_for_upload(content_type, data)
|
|
86
|
+
dest = branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}{suffix}"
|
|
87
|
+
dest.write_bytes(data)
|
|
88
|
+
raw = stack_config._read_raw()
|
|
89
|
+
block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
|
|
90
|
+
block[f"{kind}_file"] = f"{BRANDING_DIR_NAME}/{dest.name}"
|
|
91
|
+
raw["site_branding"] = block
|
|
92
|
+
stack_config._write_raw(raw)
|
|
93
|
+
return dest
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _suffix_for_upload(content_type: str, data: bytes) -> str:
|
|
97
|
+
ctype = str(content_type or "").split(";")[0].strip().lower()
|
|
98
|
+
if ctype == "image/png" or data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
99
|
+
return ".png"
|
|
100
|
+
if ctype in {"image/jpeg", "image/jpg"} or data[:3] == b"\xff\xd8\xff":
|
|
101
|
+
return ".jpg"
|
|
102
|
+
if ctype == "image/webp" or data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
103
|
+
return ".webp"
|
|
104
|
+
if ctype == "image/svg+xml" or data.lstrip()[:5] == b"<svg " or b"<svg" in data[:200]:
|
|
105
|
+
return ".svg"
|
|
106
|
+
if ctype == "image/x-icon":
|
|
107
|
+
return ".ico"
|
|
108
|
+
return ".png"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _abs_url(base: str, path: str) -> str:
|
|
112
|
+
base = str(base or "").strip().rstrip("/")
|
|
113
|
+
path = str(path or "").strip()
|
|
114
|
+
if not path:
|
|
115
|
+
return ""
|
|
116
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
117
|
+
return path
|
|
118
|
+
if not base:
|
|
119
|
+
return path
|
|
120
|
+
return urljoin(f"{base}/", path.lstrip("/"))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _browser_asset_url(base: str, path: str) -> str:
|
|
124
|
+
"""Same-origin static assets — relative paths avoid loopback leaks on HTTPS deploys."""
|
|
125
|
+
path = str(path or "").strip()
|
|
126
|
+
if path.startswith("/") and not path.startswith("//"):
|
|
127
|
+
return path
|
|
128
|
+
return _abs_url(base, path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_site_meta(
|
|
132
|
+
*,
|
|
133
|
+
app_base_url: str,
|
|
134
|
+
install_complete: bool,
|
|
135
|
+
workspace: dict[str, Any] | None = None,
|
|
136
|
+
normalize_logo: Callable[[str], str] | None = None,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Merge stack overrides, primary workspace identity, and Workframe defaults."""
|
|
139
|
+
overrides = _site_branding_raw()
|
|
140
|
+
ws = workspace or {}
|
|
141
|
+
|
|
142
|
+
title = str(overrides.get("title") or "").strip()
|
|
143
|
+
if not title and install_complete:
|
|
144
|
+
title = str(ws.get("display_name") or "").strip()
|
|
145
|
+
if not title:
|
|
146
|
+
title = DEFAULT_TITLE
|
|
147
|
+
|
|
148
|
+
description = str(overrides.get("description") or "").strip()
|
|
149
|
+
if not description and install_complete:
|
|
150
|
+
description = str(ws.get("description") or "").strip()
|
|
151
|
+
if not description:
|
|
152
|
+
description = DEFAULT_DESCRIPTION
|
|
153
|
+
|
|
154
|
+
tagline = str(ws.get("tagline") or "").strip() if install_complete else ""
|
|
155
|
+
|
|
156
|
+
theme_color = str(overrides.get("theme_color") or "").strip() or DEFAULT_THEME_COLOR
|
|
157
|
+
|
|
158
|
+
og_path = _branding_asset_path("og")
|
|
159
|
+
if og_path.is_file():
|
|
160
|
+
og_image = _browser_asset_url(app_base_url, f"/api/public/branding/og{og_path.suffix}")
|
|
161
|
+
elif install_complete:
|
|
162
|
+
logo = str(ws.get("avatar_url") or "").strip()
|
|
163
|
+
if logo and normalize_logo:
|
|
164
|
+
logo = normalize_logo(logo)
|
|
165
|
+
og_image = _browser_asset_url(app_base_url, logo) if logo else _browser_asset_url(app_base_url, DEFAULT_OG_IMAGE)
|
|
166
|
+
else:
|
|
167
|
+
og_image = _browser_asset_url(app_base_url, DEFAULT_OG_IMAGE)
|
|
168
|
+
|
|
169
|
+
fav_path = _branding_asset_path("favicon")
|
|
170
|
+
if fav_path.is_file():
|
|
171
|
+
favicon = _browser_asset_url(app_base_url, f"/api/public/branding/favicon{fav_path.suffix}")
|
|
172
|
+
else:
|
|
173
|
+
favicon = _browser_asset_url(app_base_url, DEFAULT_FAVICON)
|
|
174
|
+
|
|
175
|
+
short_name = title if len(title) <= 16 else title[:15].rstrip() + "…"
|
|
176
|
+
canonical = app_base_url.rstrip("/") + "/" if app_base_url else "/"
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
"ok": True,
|
|
180
|
+
"install_complete": bool(install_complete),
|
|
181
|
+
"title": title,
|
|
182
|
+
"short_name": short_name,
|
|
183
|
+
"description": description,
|
|
184
|
+
"tagline": tagline,
|
|
185
|
+
"theme_color": theme_color,
|
|
186
|
+
"og_image": og_image,
|
|
187
|
+
"favicon": favicon,
|
|
188
|
+
"canonical_url": canonical,
|
|
189
|
+
"manifest_url": _browser_asset_url(app_base_url, "/manifest.webmanifest"),
|
|
190
|
+
"source": {
|
|
191
|
+
"title": "stack" if overrides.get("title") else ("workspace" if install_complete and ws.get("display_name") else "default"),
|
|
192
|
+
"description": "stack" if overrides.get("description") else ("workspace" if install_complete and ws.get("description") else "default"),
|
|
193
|
+
"og_image": "upload" if og_path.is_file() else ("workspace_logo" if install_complete and ws.get("avatar_url") else "default"),
|
|
194
|
+
"favicon": "upload" if fav_path.is_file() else "default",
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def manifest_payload(meta: dict[str, Any]) -> dict[str, Any]:
|
|
200
|
+
icons = []
|
|
201
|
+
favicon = str(meta.get("favicon") or "").strip()
|
|
202
|
+
if favicon:
|
|
203
|
+
icons.append(
|
|
204
|
+
{
|
|
205
|
+
"src": favicon,
|
|
206
|
+
"sizes": "any",
|
|
207
|
+
"type": "image/svg+xml" if favicon.endswith(".svg") else "image/png",
|
|
208
|
+
"purpose": "any",
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
return {
|
|
212
|
+
"name": meta.get("title") or DEFAULT_TITLE,
|
|
213
|
+
"short_name": meta.get("short_name") or DEFAULT_SHORT_NAME,
|
|
214
|
+
"description": meta.get("description") or DEFAULT_DESCRIPTION,
|
|
215
|
+
"start_url": "./",
|
|
216
|
+
"display": "standalone",
|
|
217
|
+
"background_color": meta.get("theme_color") or DEFAULT_THEME_COLOR,
|
|
218
|
+
"theme_color": meta.get("theme_color") or DEFAULT_THEME_COLOR,
|
|
219
|
+
"orientation": "any",
|
|
220
|
+
"icons": icons or [{"src": "./favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def link_preview_html(meta: dict[str, Any]) -> str:
|
|
225
|
+
title = _html_escape(str(meta.get("title") or DEFAULT_TITLE))
|
|
226
|
+
description = _html_escape(str(meta.get("description") or DEFAULT_DESCRIPTION))
|
|
227
|
+
canonical = _html_escape(str(meta.get("canonical_url") or "/"))
|
|
228
|
+
og_image = _html_escape(str(meta.get("og_image") or ""))
|
|
229
|
+
theme = _html_escape(str(meta.get("theme_color") or DEFAULT_THEME_COLOR))
|
|
230
|
+
favicon = _html_escape(str(meta.get("favicon") or DEFAULT_FAVICON))
|
|
231
|
+
return f"""<!doctype html>
|
|
232
|
+
<html lang="en">
|
|
233
|
+
<head>
|
|
234
|
+
<meta charset="utf-8" />
|
|
235
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
236
|
+
<title>{title}</title>
|
|
237
|
+
<meta name="description" content="{description}" />
|
|
238
|
+
<meta name="theme-color" content="{theme}" />
|
|
239
|
+
<link rel="icon" href="{favicon}" />
|
|
240
|
+
<meta property="og:type" content="website" />
|
|
241
|
+
<meta property="og:site_name" content="{title}" />
|
|
242
|
+
<meta property="og:title" content="{title}" />
|
|
243
|
+
<meta property="og:description" content="{description}" />
|
|
244
|
+
<meta property="og:url" content="{canonical}" />
|
|
245
|
+
<meta property="og:image" content="{og_image}" />
|
|
246
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
247
|
+
<meta name="twitter:title" content="{title}" />
|
|
248
|
+
<meta name="twitter:description" content="{description}" />
|
|
249
|
+
<meta name="twitter:image" content="{og_image}" />
|
|
250
|
+
<meta http-equiv="refresh" content="0;url={canonical}" />
|
|
251
|
+
</head>
|
|
252
|
+
<body><p><a href="{canonical}">{title}</a></p></body>
|
|
253
|
+
</html>
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def branding_asset_bytes(kind: str) -> tuple[bytes, str] | None:
|
|
258
|
+
path = _branding_asset_path(kind)
|
|
259
|
+
if not path.is_file():
|
|
260
|
+
return None
|
|
261
|
+
ctype = _MIME_BY_SUFFIX.get(path.suffix.lower(), "application/octet-stream")
|
|
262
|
+
return path.read_bytes(), ctype
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _html_escape(value: str) -> str:
|
|
266
|
+
return (
|
|
267
|
+
value.replace("&", "&")
|
|
268
|
+
.replace("<", "<")
|
|
269
|
+
.replace(">", ">")
|
|
270
|
+
.replace('"', """)
|
|
271
|
+
)
|