feat(site): 导航收敛为特性/文档/定价,新增定价页与站点自包含截图,dist 产物入库
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

- homepage/index.html:菜单改为 特性/文档/定价,正文按钮指向 /docs/ 与 /pricing
- homepage/pricing.html:新增许可与定价页(License & Pricing)
- homepage/assets/screenshots/:6 张文档截图收进站点,构建不再依赖仓库 docs/ 目录
- scripts/build-homepage.py:取图优先 homepage/assets;模板内置 h5 移动端修复(重建不丢);dist/assets 不再重复复制截图
- homepage/dist/:23 个构建产物入库,nginx 直接托管(重建幂等)
This commit is contained in:
Logan.Li committed 2026-11-01 22:54:13 +08:00
1 parent b8eca87a89
commit e1e8243622
32 files changed
+1853 -7

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把 /srv/LokiStack/docs/user-guide/*.md 按 manifest.json 静态渲染成文档站,
输出到 /srv/LokiStack/homepage/dist/docs/ (与 landing 一起构成 dist 产物) // Logan.Li
"""
import json
import os
import re
import shutil
from pathlib import Path
import markdown
SRC = Path("/srv/LokiStack/docs")
GUIDE = SRC / "user-guide"
OUT = Path("/srv/LokiStack/homepage/dist")
DOCS_OUT = OUT / "docs"
CSS = """
:root{--brand:#059669;--brand-d: #047857;--ink:#0f172a;--muted:#5b6676;--line:#e6e9ee;--bg:#fff;--side:#f7f9f8}
*{box-sizing:border-box}
body{margin:0;font:15px/1.75 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;color:var(--ink);background:var(--bg)}
a{color:var(--brand-d);text-decoration:none}a:hover{text-decoration:underline}
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:18px;height:56px;padding:0 20px;background:rgba(255,255,255,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.brand{display:flex;align-items:center;gap:9px;font-weight:800;font-size:15px;color:var(--ink)}
.brand i{width:26px;height:26px;border-radius:8px;background:var(--brand);color:#fff;display:grid;place-items:center;font-style:normal;font-weight:800;font-size:14px}
.topnav{display:flex;gap:16px;margin-left:auto;font-size:14px}
.topnav a{color:var(--muted)}.topnav a:hover{color:var(--brand-d)}
.layout{display:grid;grid-template-columns:250px minmax(0,1fr) 200px;gap:34px;max-width:1240px;margin:0 auto;padding:26px 20px 70px}
aside.side{position:sticky;top:80px;align-self:start;max-height:calc(100vh - 100px);overflow:auto;font-size:14px}
.side h4{margin:18px 0 6px;font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:#93a0af;font-weight:700}
.side a{display:block;padding:5px 10px;border-radius:7px;color:#3b4656}
.side a:hover{background:var(--side);text-decoration:none}
.side a.on{background:#e8f7f1;color:var(--brand-d);font-weight:650}
article{min-width:0}
article h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.meta{color:#98a2b3;font-size:12.5px;margin-bottom:22px}
article h2{font-size:21px;margin:32px 0 10px;padding-bottom:7px;border-bottom:1px solid var(--line)}
article h3{font-size:17px;margin:24px 0 8px}
article p{margin:12px 0}
article img{max-width:100%;height:auto;border:1px solid var(--line);border-radius:10px;margin:14px 0;box-shadow:0 1px 3px rgba(16,24,40,.06)}
article pre{background:#0f172a;color:#e6edf3;padding:14px 16px;border-radius:10px;overflow:auto;font-size:13px;line-height:1.6}
article code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
article :not(pre)>code{background:#f1f5f9;padding:2px 5px;border-radius:5px;color:#0f766e}
article table{border-collapse:collapse;width:100%;margin:14px 0;font-size:14px}
article th,article td{border:1px solid var(--line);padding:8px 10px;text-align:left}
article th{background:var(--side)}
article blockquote{margin:14px 0;padding:10px 16px;border-left:3px solid var(--brand);background:#f4fbf8;color:#33415c;border-radius:0 8px 8px 0}
.toc{position:sticky;top:84px;align-self:start;font-size:13px;color:var(--muted)}
.toc b{display:block;font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:#93a0af;margin-bottom:8px}
.toc a{display:block;padding:3px 0 3px 10px;border-left:2px solid var(--line);color:#5b6676}
.toc a:hover{color:var(--brand-d);border-color:var(--brand)}
.pager{display:flex;justify-content:space-between;gap:16px;margin-top:46px;padding-top:18px;border-top:1px solid var(--line);font-size:14px}
footer{border-top:1px solid var(--line);padding:20px;text-align:center;color:#98a2b3;font-size:12.5px}
@media (max-width:1080px){.layout{grid-template-columns:230px minmax(0,1fr)}.toc{display:none}}
@media (max-width:820px){.layout{grid-template-columns:1fr;padding-top:16px}aside.side{position:static;max-height:none;border-bottom:1px solid var(--line);padding-bottom:10px}.topnav{display:none}}
"""
H5_FIX = """<style id="h5-fix">@media (max-width:820px){body{-webkit-text-size-adjust:100%}nav a,nav button,.nav-links a,.nav-menu a,.nav-item,.topnav a,.menu a,.side a,.sidebar a,.docs-side a,.toc a,footer a,a.btn,.plan .btn,button.btn{display:inline-flex;align-items:center;min-height:44px}.side a,.sidebar a,.docs-side a{padding:10px 12px;border-radius:8px}.toc a{padding:8px 0}pre{max-width:100%;overflow-x:auto}.plans,.grid,.grid-2,.grid-3{grid-template-columns:1fr!important}.hero h1,.hero-title{font-size:clamp(24px,7vw,34px)!important}.tbl-scroll{max-width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}}</style>
<script id="h5-fix-js">(function(){var f=function(){document.querySelectorAll("table").forEach(function(t){var p=t.parentElement;if(p&&p.classList&&p.classList.contains("tbl-scroll"))return;var w=document.createElement("div");w.className="tbl-scroll";p.insertBefore(w,t);w.appendChild(t);});};if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",f);else f();})();</script>"""
PAGE = """<!doctype html>
<html lang="zh-CN"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} · LokiStack 文档</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23059669'/%3E%3Ctext x='32' y='44' font-size='34' font-weight='800' fill='%23fff' text-anchor='middle' font-family='sans-serif'%3EL%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/docs/assets/loki-docs.css">
{h5fix}
</head><body>
<div class="topbar">
<a class="brand" href="/"><i>L</i>LokiStack</a>
<span style="color:#98a2b3;font-size:13px">文档</span>
<nav class="topnav"><a href="/">官网</a><a href="/docs/" class="on">文档</a></nav>
</div>
<div class="layout">
<aside class="side">{side}</aside>
<article>
<h1>{h1}</h1>
<div class="meta">{group} · 更新于 {updated}</div>
{body}
<div class="pager">{pager}</div>
</article>
<nav class="toc"><b>本页目录</b>{toc}</nav>
</div>
<footer>LokiStack · <a href="/">返回官网</a> · © 日理万机</footer>
</body></html>
"""
def slugify(t):
return re.sub(r"[^\w\u4e00-\u9fa5]+", "-", t.lower()).strip("-")
def main():
manifest = json.loads((GUIDE / "manifest.json").read_text(encoding="utf-8"))
groups = manifest.get("groups", [])
version = manifest.get("productVersion", "")
pages = []
for g in groups:
for p in g.get("pages", []):
pages.append({
"group": g.get("name", ""),
"file": p.get("file", ""),
"slug": p.get("slug") or slugify(p.get("title", "")),
"title": p.get("title", ""),
"updated": p.get("updated", ""),
})
print(f" manifest: {len(groups)} 组 / {len(pages)} 页 (v{version})")
# 资源
DOCS_OUT.mkdir(parents=True, exist_ok=True)
# 文档资源:优先用 homepage/assets(站点自包含,不再依赖仓库 docs/ 目录) // Logan.Li
hp_assets = Path("/srv/LokiStack/homepage/assets")
if hp_assets.exists():
shutil.copytree(hp_assets, DOCS_OUT / "assets", dirs_exist_ok=True)
if (SRC / "assets").exists():
shutil.copytree(SRC / "assets", DOCS_OUT / "assets", dirs_exist_ok=True)
(DOCS_OUT / "assets").mkdir(parents=True, exist_ok=True)
(DOCS_OUT / "assets" / "loki-docs.css").write_text(CSS, encoding="utf-8")
print(f" assets: {(SRC / 'assets').exists() and '已复制' or '无'}(含 screenshots)")
md = markdown.Markdown(extensions=["extra", "toc", "sane_lists", "nl2br"],
extension_configs={"toc": {"permalink": False}})
# 侧栏(所有页共用一个 DOM)
side = ""
last_group = None
for p in pages:
if p["group"] != last_group:
side += f'<h4>{p["group"]}</h4>'
last_group = p["group"]
side += f'<a data-slug="{p["slug"]}" href="/docs/{p["slug"]}/">{p["title"]}</a>'
out_pages = []
for i, p in enumerate(pages):
src = GUIDE / p["file"]
if not src.exists():
print(f" ⚠️ 缺文件: {p['file']}")
continue
text = src.read_text(encoding="utf-8")
# 第一行若是 H1 就当标题用掉
h1 = p["title"]
m = re.match(r"^\s*#\s+(.+)$", text, re.M)
if m:
h1 = m.group(1).strip()
text = re.sub(r"^\s*#\s+.+\n", "", text, count=1)
md.reset()
body = md.convert(text)
toc_html = ""
for t in md.toc_tokens:
if t["level"] <= 3:
toc_html += f'<a href="#{t["id"]}">{t["name"]}</a>'
# 图片相对路径: docs 页里的 images 走 /docs/assets/screenshots/
body = re.sub(r'src="(?!https?:|/|data:)', 'src="/docs/assets/', body)
pager = ""
if i > 0:
pager += f'<a href="/docs/{pages[i-1]["slug"]}/">← {pages[i-1]["title"]}</a>'
else:
pager += "<span></span>"
if i < len(pages) - 1:
pager += f'<a href="/docs/{pages[i+1]["slug"]}/">{pages[i+1]["title"]} →</a>'
html = PAGE.format(h5fix=H5_FIX, title=h1, h1=h1, group=p["group"], updated=p["updated"] or "—",
body=body, side=side, toc=toc_html, pager=pager)
html = html.replace('href="/docs/%s/"' % p["slug"], 'href="/docs/%s/" class="on"' % p["slug"])
d = DOCS_OUT / p["slug"]
d.mkdir(parents=True, exist_ok=True)
(d / "index.html").write_text(html, encoding="utf-8")
out_pages.append(p)
print(f" ✓ /docs/{p['slug']}/ ({len(html)}B)")
# /docs/ 入口 = 第一页内容
if out_pages:
first = out_pages[0]
idx = (DOCS_OUT / first["slug"] / "index.html").read_text(encoding="utf-8")
(DOCS_OUT / "index.html").write_text(idx, encoding="utf-8")
print(f" ✓ /docs/ → {first['title']}")
# 落地页与资源一并进 dist(与其它三站一致的“dist 即产物”结构)
hp = Path("/srv/LokiStack/homepage")
for page in ("index.html", "pricing.html"):
if (hp / page).exists():
shutil.copy2(hp / page, OUT / page)
if (hp / "assets").exists():
# 只同步顶层静态资源(site.css 等);截图仅由 docs 走 /docs/assets/screenshots/,避免 dist 里存两份副本 // Logan.Li
(OUT / "assets").mkdir(parents=True, exist_ok=True)
for item in (hp / "assets").iterdir():
if item.is_file():
shutil.copy2(item, OUT / "assets" / item.name)
print(f" 落地页/资源已同步进 dist")
print(f" 产物文件数: {sum(1 for _ in OUT.rglob('*') if _.is_file())}")
if __name__ == "__main__":
main()