@windcoder/astro-theme-orchid 0.2.1 → 0.3.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/README.md +3 -3
- package/package.json +4 -2
- package/scripts/check-friends.mjs +227 -0
- package/scripts/migrate-posts.mjs +375 -0
- package/src/components/Analytics.astro +3 -0
- package/src/components/BaseLayout.astro +40 -1
- package/src/components/analytics/WebVitals.astro +72 -0
- package/src/i18n/en.ts +6 -0
- package/src/i18n/zh-CN.ts +6 -0
- package/src/index.ts +10 -0
- package/src/plugins/rehype.ts +10 -2
- package/src/routes/bangumi.astro +110 -0
- package/src/routes/manifest.webmanifest.ts +33 -0
- package/src/routes/sw.js.ts +127 -0
- package/src/schema.ts +110 -1
- package/src/styles/global.css +118 -0
|
@@ -50,6 +50,17 @@ const showBanner =
|
|
|
50
50
|
/* hero 文案(§4.42):quote 留空不渲染;有 banner 时叠加其上,否则渲染为列表上方独立区块 */
|
|
51
51
|
const heroEnabled = config.banner.enabled && Boolean(config.banner.quote);
|
|
52
52
|
const heroOnBanner = heroEnabled && showBanner && page === "home";
|
|
53
|
+
/* 全站背景壁纸(§4.92):与 banner 相互独立;数组时构建期随机选一张(每页各自取,零 JS) */
|
|
54
|
+
const bgSrc = Array.isArray(config.background.src)
|
|
55
|
+
? config.background.src.length
|
|
56
|
+
? config.background.src[
|
|
57
|
+
Math.floor(Math.random() * config.background.src.length)
|
|
58
|
+
]
|
|
59
|
+
: ""
|
|
60
|
+
: config.background.src || "";
|
|
61
|
+
const showWallpaper = config.background.enabled && Boolean(bgSrc);
|
|
62
|
+
/* 模糊边缘会泛出透明底:按模糊量轻微放大补偿(1px blur ≈ 1% 放大),服务端算好 scale */
|
|
63
|
+
const bgScale = (1 + config.background.blur / 100).toFixed(3);
|
|
53
64
|
/* 字体角色映射(§4.66):配置了 role 的字体生成 html:root 规则,把主题三角色
|
|
54
65
|
变量重定向为 var(字体变量), 原角色回退栈(取自 global.css :root 默认值,
|
|
55
66
|
字体未覆盖的字形如中文自然回落)。html:root Specificity (0,1,1) 高于
|
|
@@ -70,7 +81,7 @@ const fontRoleCss = config.fonts
|
|
|
70
81
|
---
|
|
71
82
|
|
|
72
83
|
<!doctype html>
|
|
73
|
-
<html lang={config.lang} style={`--accent-h:${h};--accent-s:${s}%;--accent-l:${l}%;--page-width:${config.pageWidth}px`} data-accent-h={h} data-color-mode={config.colorMode}>
|
|
84
|
+
<html lang={config.lang} style={`--accent-h:${h};--accent-s:${s}%;--accent-l:${l}%;--page-width:${config.pageWidth}px${config.darkMode.adaptExternalImages ? ";--img-adapt-b:.85" : ""}`} data-accent-h={h} data-color-mode={config.colorMode}>
|
|
74
85
|
<head>
|
|
75
86
|
<meta charset="utf-8" />
|
|
76
87
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -97,6 +108,8 @@ const fontRoleCss = config.fonts
|
|
|
97
108
|
<link rel="alternate" type="application/rss+xml" title={config.title} href={Astro.site ? new URL("rss.xml", Astro.site).href : "/rss.xml"} />
|
|
98
109
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f4ef" />
|
|
99
110
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#131516" />
|
|
111
|
+
{/* PWA(§4.95):manifest 清单链接,端点由 pwa.enabled 门控注入 */}
|
|
112
|
+
{config.pwa.enabled && <link rel="manifest" href="/manifest.webmanifest" />}
|
|
100
113
|
{/* OG/Twitter meta(b9.4):ogImage 仅文章页传入;绝对链接依赖 site 配置 */}
|
|
101
114
|
<meta property="og:type" content={ogImage ? 'article' : 'website'} />
|
|
102
115
|
<meta property="og:title" content={pageTitle} />
|
|
@@ -122,10 +135,36 @@ const fontRoleCss = config.fonts
|
|
|
122
135
|
<script is:inline>
|
|
123
136
|
(function(){var m=localStorage.getItem('qy-mode');var d=document.documentElement.dataset.colorMode||'system';if(m==='dark'||(!m&&d==='dark')||(!m&&d==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');var h=localStorage.getItem('qy-hue');if(h)document.documentElement.style.setProperty('--accent-h',h);})();
|
|
124
137
|
</script>
|
|
138
|
+
{/* PWA(§4.95):SW 注册。load 后注册不抢占首屏;is:inline + define:vars 在
|
|
139
|
+
ClientRouter VT swap 时不重复执行,全站仅注册一次;注册失败静默(离线兜底
|
|
140
|
+
属增强能力,不影响正常浏览) */}
|
|
141
|
+
{config.pwa.enabled && (
|
|
142
|
+
<script is:inline define:vars={{ swUrl: "/sw.js" }}>
|
|
143
|
+
if ("serviceWorker" in navigator) {
|
|
144
|
+
window.addEventListener("load", function () {
|
|
145
|
+
navigator.serviceWorker.register(swUrl).catch(function () {});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
</script>
|
|
149
|
+
)}
|
|
125
150
|
</head>
|
|
126
151
|
<!-- 客户端动态文案载体(i18n,M3b-b5):app.ts 读取,VT swap 后随新 body 重建 -->
|
|
127
152
|
<script type="application/json" id="qy-ui-labels" set:html={JSON.stringify({ close: t('close'), prevImage: t('prevImage'), nextImage: t('nextImage'), copied: t('copied'), fullscreen: t('fullscreen'), slideshow: t('slideshow'), thumbs: t('thumbs'), sharePoster: t('sharePoster'), generatingPoster: t('generatingPoster'), copyLink: t('copyLink'), savePoster: t('savePoster'), scanToRead: t('scanToRead'), author: t('author'), searchLoading: t('searchLoading'), searchNoResults: t('searchNoResults'), searchUnavailable: t('searchUnavailable'), searchTypeSomething: t('searchTypeSomething') })} />
|
|
128
153
|
<body data-page={page}>
|
|
154
|
+
<!-- 全站背景壁纸(§4.92):fixed 于视口、z-index:-1,叠在 body 传播的画布底色之上、
|
|
155
|
+
内容之下——壁纸加载中仍有画布底色兜底;enabled=false / src 未配置不渲染 -->
|
|
156
|
+
{
|
|
157
|
+
showWallpaper && (
|
|
158
|
+
<div
|
|
159
|
+
class="site-wallpaper"
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
style={`--wp-dim:${config.background.dimOpacity};--wp-dim-dark:${config.background.darkDimOpacity};--wp-blur:${config.background.blur}px;--wp-scale:${bgScale}`}
|
|
162
|
+
>
|
|
163
|
+
<img src={bgSrc} alt="" loading="eager" decoding="async" />
|
|
164
|
+
<div class="site-wallpaper-dim" />
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
129
168
|
<a class="skip-link" href="#main">{t('skipToContent')}</a>
|
|
130
169
|
<Header />
|
|
131
170
|
<!-- 首页 banner(§4.42,hero+壁纸合并单配置):Header 之后下沉横幅 + 黑蒙层;enabled=false / src 未配置 / homeOnly 非首页不渲染 -->
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Web Vitals 通用上报(§4.91):与统计厂商无关的 Core Web Vitals 采集。
|
|
4
|
+
* Google 官方 web-vitals 库经 Astro bundled script 打包(约 2KB gzip),
|
|
5
|
+
* LCP/CLS/INP/FCP/TTFB 五指标就绪即上报。
|
|
6
|
+
* 载荷以 text/plain 载 JSON 经 sendBeacon 发送(CORS 安全清单类型,免预检),
|
|
7
|
+
* sendBeacon 排队失败回退 fetch keepalive。endpoint 留空由 Analytics.astro 门控不注入。
|
|
8
|
+
* 配置经 DOM data 属性传入(bundled script 无 define:vars,SakuraEffect 同款惯例)。
|
|
9
|
+
*/
|
|
10
|
+
interface Props {
|
|
11
|
+
/** 上报端点(POST) */
|
|
12
|
+
endpoint: string;
|
|
13
|
+
/** 站点标识 */
|
|
14
|
+
siteId?: string;
|
|
15
|
+
/** 采样率 0~1 */
|
|
16
|
+
sampleRate?: number;
|
|
17
|
+
/** 控制台打印载荷(调试) */
|
|
18
|
+
debug?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { endpoint, siteId = "", sampleRate = 1, debug = false } = Astro.props;
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<div
|
|
25
|
+
id="orchid-webvitals"
|
|
26
|
+
data-endpoint={endpoint}
|
|
27
|
+
data-site-id={siteId}
|
|
28
|
+
data-sample-rate={sampleRate}
|
|
29
|
+
data-debug={debug ? "true" : undefined}
|
|
30
|
+
hidden
|
|
31
|
+
></div>
|
|
32
|
+
|
|
33
|
+
<script>
|
|
34
|
+
import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from "web-vitals";
|
|
35
|
+
|
|
36
|
+
const el = document.getElementById("orchid-webvitals");
|
|
37
|
+
const endpoint = el?.dataset.endpoint ?? "";
|
|
38
|
+
const siteId = el?.dataset.siteId ?? "";
|
|
39
|
+
const sampleRate = Number(el?.dataset.sampleRate ?? 1);
|
|
40
|
+
const debug = el?.dataset.debug === "true";
|
|
41
|
+
|
|
42
|
+
if (endpoint && Math.random() < sampleRate) {
|
|
43
|
+
const send = (metric: Metric) => {
|
|
44
|
+
const payload = JSON.stringify({
|
|
45
|
+
name: metric.name,
|
|
46
|
+
value: Math.round(metric.value),
|
|
47
|
+
rating: metric.rating,
|
|
48
|
+
delta: Math.round(metric.delta),
|
|
49
|
+
id: metric.id,
|
|
50
|
+
navType: metric.navigationType ?? "",
|
|
51
|
+
path: location.pathname,
|
|
52
|
+
site: siteId,
|
|
53
|
+
});
|
|
54
|
+
if (debug) console.log("[web-vitals]", payload);
|
|
55
|
+
const blob = new Blob([payload], { type: "text/plain;charset=UTF-8" });
|
|
56
|
+
if (!navigator.sendBeacon(endpoint, blob)) {
|
|
57
|
+
// 排队失败(页面卸载瞬间的兜底)
|
|
58
|
+
fetch(endpoint, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
body: payload,
|
|
61
|
+
keepalive: true,
|
|
62
|
+
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
63
|
+
}).catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
onCLS(send);
|
|
67
|
+
onFCP(send);
|
|
68
|
+
onINP(send);
|
|
69
|
+
onLCP(send);
|
|
70
|
+
onTTFB(send);
|
|
71
|
+
}
|
|
72
|
+
</script>
|
package/src/i18n/en.ts
CHANGED
|
@@ -77,6 +77,12 @@ const en: Record<UiKey, string> = {
|
|
|
77
77
|
avatarAlt: "Avatar of {name}",
|
|
78
78
|
noFriends: "No friend links yet",
|
|
79
79
|
applyFriend: "Apply for a link",
|
|
80
|
+
/* Bangumi watching page (§4.93) */
|
|
81
|
+
bangumiEmpty: "Nothing being watched yet",
|
|
82
|
+
bangumiFail: "Failed to fetch Bangumi data. Rebuild the site to retry.",
|
|
83
|
+
epProgress: "Ep. {n} / {total}",
|
|
84
|
+
epProgressOpen: "Ep. {n}",
|
|
85
|
+
myRate: "My rating {n}",
|
|
80
86
|
guestbook: "Guestbook",
|
|
81
87
|
guestbookSub: "Leave your trace here",
|
|
82
88
|
commentComingSoon: "Comments are coming soon. Stay tuned.",
|
package/src/i18n/zh-CN.ts
CHANGED
|
@@ -90,6 +90,12 @@ const zhCN = {
|
|
|
90
90
|
avatarAlt: "{name} 的头像",
|
|
91
91
|
noFriends: "还没有友链",
|
|
92
92
|
applyFriend: "申请友链",
|
|
93
|
+
/* 番组追番页(§4.93) */
|
|
94
|
+
bangumiEmpty: "还没有在看中的番剧",
|
|
95
|
+
bangumiFail: "番组数据拉取失败,请重新构建站点后重试",
|
|
96
|
+
epProgress: "看到 {n} / {total} 集",
|
|
97
|
+
epProgressOpen: "看到 {n} 集",
|
|
98
|
+
myRate: "我的评分 {n}",
|
|
93
99
|
guestbook: "留言板",
|
|
94
100
|
guestbookSub: "欢迎留下你的足迹",
|
|
95
101
|
commentComingSoon: "评论区即将开放,敬请期待。",
|
package/src/index.ts
CHANGED
|
@@ -344,6 +344,10 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
|
|
|
344
344
|
if (config.pages.moments) {
|
|
345
345
|
routes.push(["/moments/[...page]", "moments/[...page].astro"]);
|
|
346
346
|
}
|
|
347
|
+
// 番组追番页(§4.93):构建期拉 bgm.tv「在看」收藏;enabled + userId 双条件才注入
|
|
348
|
+
if (config.bangumi.enabled && config.bangumi.userId.trim()) {
|
|
349
|
+
routes.push(["/bangumi", "bangumi.astro"]);
|
|
350
|
+
}
|
|
347
351
|
// 相关/随机文章推荐(P1-12):随机栏数据源
|
|
348
352
|
if (config.relatedPosts.enabled) {
|
|
349
353
|
routes.push(["/api/allPostMeta.json", "api/allPostMeta.json.ts"]);
|
|
@@ -352,6 +356,12 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
|
|
|
352
356
|
if (config.og.enabled) {
|
|
353
357
|
routes.push(["/og/[...slug]", "og/[...slug].ts"]);
|
|
354
358
|
}
|
|
359
|
+
// PWA(§4.95):manifest 与 Service Worker 为构建期生成的端点,enabled 才注入;
|
|
360
|
+
// head 里的 manifest 链接与 SW 注册脚本由 BaseLayout 按 pwa.enabled 输出
|
|
361
|
+
if (config.pwa.enabled) {
|
|
362
|
+
routes.push(["/manifest.webmanifest", "manifest.webmanifest.ts"]);
|
|
363
|
+
routes.push(["/sw.js", "sw.js.ts"]);
|
|
364
|
+
}
|
|
355
365
|
for (const [pattern, file] of routes) {
|
|
356
366
|
injectRoute({
|
|
357
367
|
pattern,
|
package/src/plugins/rehype.ts
CHANGED
|
@@ -35,12 +35,20 @@ function getClassList(props: any): string[] {
|
|
|
35
35
|
/* ---------------------------------------------------------------- 外链 */
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* 外链新窗口打开:http(s) 绝对链接追加 target="_blank" rel="noopener noreferrer"
|
|
38
|
+
* 外链新窗口打开:http(s) 绝对链接追加 target="_blank" rel="noopener noreferrer";
|
|
39
|
+
* 外链 <img> 追加 data-external 标记(暗色外链图适配 §4.89 的 CSS 挂点,标记本身零副作用)
|
|
39
40
|
*/
|
|
40
41
|
export function rehypeExternalLinks(): Transformer {
|
|
41
42
|
return (tree) => {
|
|
42
43
|
visit(tree, (node) => {
|
|
43
|
-
if (node.type !== "element"
|
|
44
|
+
if (node.type !== "element") return;
|
|
45
|
+
if (node.tagName === "img") {
|
|
46
|
+
if (/^https?:\/\//i.test(String(node.properties?.src ?? ""))) {
|
|
47
|
+
node.properties["data-external"] = "true";
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (node.tagName !== "a") return;
|
|
44
52
|
const href = String(node.properties?.href ?? "");
|
|
45
53
|
if (!/^https?:\/\//i.test(href)) return;
|
|
46
54
|
node.properties.target = "_blank";
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
3
|
+
import config from "virtual:theme/config";
|
|
4
|
+
import { t } from "../i18n/index.js";
|
|
5
|
+
|
|
6
|
+
/* 番组追番页(§4.93):构建期从 bgm.tv v0 API 拉取用户「在看」动画收藏,
|
|
7
|
+
渲染为静态页。外部依赖 fail-soft:请求失败不中断构建,页面照常生成并提示。 */
|
|
8
|
+
|
|
9
|
+
interface BgmSubject {
|
|
10
|
+
id: number;
|
|
11
|
+
name: string;
|
|
12
|
+
name_cn?: string;
|
|
13
|
+
score?: number;
|
|
14
|
+
eps?: number;
|
|
15
|
+
images?: { common?: string; medium?: string; large?: string };
|
|
16
|
+
}
|
|
17
|
+
interface BgmCollection {
|
|
18
|
+
updated_at?: string;
|
|
19
|
+
subject: BgmSubject;
|
|
20
|
+
ep_status?: number;
|
|
21
|
+
rate?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LIMIT = 50;
|
|
25
|
+
/* 50 * 20 = 1000 条安全上限:防止分页异常(total 失真)导致构建挂死 */
|
|
26
|
+
const MAX_PAGES = 20;
|
|
27
|
+
const UA =
|
|
28
|
+
"astro-theme-orchid (+https://github.com/iwinder/qy-astro-theme-orchid)";
|
|
29
|
+
|
|
30
|
+
let items: BgmCollection[] = [];
|
|
31
|
+
let failed = false;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const headers = { "User-Agent": UA, Accept: "application/json" };
|
|
35
|
+
for (let offset = 0; offset < LIMIT * MAX_PAGES; offset += LIMIT) {
|
|
36
|
+
const url = `https://api.bgm.tv/v0/users/${config.bangumi.userId}/collections?subject_type=2&type=3&limit=${LIMIT}&offset=${offset}`;
|
|
37
|
+
const res = await fetch(url, {
|
|
38
|
+
headers,
|
|
39
|
+
signal: AbortSignal.timeout(10_000),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) throw new Error(`bgm.tv responded ${res.status}`);
|
|
42
|
+
const page = (await res.json()) as { data?: BgmCollection[] };
|
|
43
|
+
const batch = page.data ?? [];
|
|
44
|
+
items = items.concat(batch);
|
|
45
|
+
if (batch.length < LIMIT) break;
|
|
46
|
+
}
|
|
47
|
+
/* 最近在看优先:按收藏更新时间倒序 */
|
|
48
|
+
items.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
|
|
49
|
+
} catch {
|
|
50
|
+
failed = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* 进度文案:eps 有值时 clamp 到总集数并显示 x/y,否则只显示已看集数 */
|
|
54
|
+
function progress(item: BgmCollection): string {
|
|
55
|
+
const eps = item.subject.eps ?? 0;
|
|
56
|
+
const watched = item.ep_status ?? 0;
|
|
57
|
+
return eps > 0
|
|
58
|
+
? t("epProgress", {
|
|
59
|
+
n: Math.min(watched, eps),
|
|
60
|
+
total: eps,
|
|
61
|
+
})
|
|
62
|
+
: t("epProgressOpen", { n: watched });
|
|
63
|
+
}
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
<BaseLayout title={config.bangumi.title} description={config.bangumi.description} page="bangumi">
|
|
67
|
+
<header class="archive-head reveal">
|
|
68
|
+
<h1 class="page-title">{config.bangumi.title}</h1>
|
|
69
|
+
{
|
|
70
|
+
config.bangumi.description && (
|
|
71
|
+
<p class="page-sub">{config.bangumi.description}</p>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
</header>
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
items.length > 0 ? (
|
|
78
|
+
<div class="bangumi-grid reveal">
|
|
79
|
+
{items.map((item) => {
|
|
80
|
+
const s = item.subject;
|
|
81
|
+
const cover = s.images?.common ?? s.images?.medium ?? s.images?.large;
|
|
82
|
+
return (
|
|
83
|
+
<a
|
|
84
|
+
class="bangumi-card card"
|
|
85
|
+
href={`https://bgm.tv/subject/${s.id}`}
|
|
86
|
+
target="_blank"
|
|
87
|
+
rel="noopener nofollow"
|
|
88
|
+
>
|
|
89
|
+
<div class="bangumi-cover">
|
|
90
|
+
{cover && <img src={cover} alt={s.name_cn || s.name} loading="lazy" decoding="async" />}
|
|
91
|
+
{typeof s.score === "number" && s.score > 0 && (
|
|
92
|
+
<span class="bangumi-score">{s.score.toFixed(1)}</span>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
<div class="bangumi-title">{s.name_cn || s.name}</div>
|
|
96
|
+
<div class="bangumi-meta">
|
|
97
|
+
<span class="bangumi-progress">{progress(item)}</span>
|
|
98
|
+
{typeof item.rate === "number" && item.rate > 0 && (
|
|
99
|
+
<span class="bangumi-rate">{t("myRate", { n: item.rate })}</span>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</a>
|
|
103
|
+
);
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
) : (
|
|
107
|
+
<p class="tag-empty reveal">{failed ? t("bangumiFail") : t("bangumiEmpty")}</p>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
</BaseLayout>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import config from "virtual:theme/config";
|
|
2
|
+
import type { APIRoute } from "astro";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PWA manifest(§4.95):构建期产出 /manifest.webmanifest。
|
|
6
|
+
* - scope/start_url 由 site 根路径派生(主题假定部署在站点根路径);
|
|
7
|
+
* - name/description 留空回落站点 title/description,short_name 留空回落 name;
|
|
8
|
+
* - icons 按 pwa.icons 全字段映射(可安装性至少需要 192x192 + 512x512 各一张);
|
|
9
|
+
* - enabled=false 时本端点根本不注入(index.ts 门控)。
|
|
10
|
+
*/
|
|
11
|
+
export const GET: APIRoute = ({ site }) => {
|
|
12
|
+
const base = new URL("/", site ?? new URL("https://blog.example.com"))
|
|
13
|
+
.pathname;
|
|
14
|
+
const manifest = {
|
|
15
|
+
name: config.pwa.name || config.title,
|
|
16
|
+
short_name: config.pwa.shortName || config.pwa.name || config.title,
|
|
17
|
+
description: config.pwa.description || config.description,
|
|
18
|
+
start_url: base,
|
|
19
|
+
scope: base,
|
|
20
|
+
display: config.pwa.display,
|
|
21
|
+
background_color: config.pwa.backgroundColor,
|
|
22
|
+
theme_color: config.pwa.themeColor,
|
|
23
|
+
icons: config.pwa.icons.map((icon) => ({
|
|
24
|
+
src: icon.src,
|
|
25
|
+
sizes: icon.sizes,
|
|
26
|
+
type: icon.type,
|
|
27
|
+
purpose: icon.purpose,
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
return new Response(JSON.stringify(manifest, null, 2), {
|
|
31
|
+
headers: { "Content-Type": "application/manifest+json; charset=utf-8" },
|
|
32
|
+
});
|
|
33
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PWA Service Worker(§4.95):构建期生成 /sw.js 文本,手写、零客户端运行时依赖。
|
|
5
|
+
* - 缓存名带构建号(DEV 固定 "dev"):新构建上线后旧缓存整体作废,activate 全清;
|
|
6
|
+
* - 三条策略:① 导航请求 network-first,成功回填缓存,离线回落自身缓存、再兜底
|
|
7
|
+
* 预缓存的首页;② /_astro/ 产物带内容哈希,cache-first;③ 其余同源 GET 走
|
|
8
|
+
* stale-while-revalidate,缓存桶超 60 条删最早一条防膨胀;
|
|
9
|
+
* - 非 GET、跨域、/api/(随机数据端点)、/og/(分享图 PNG 较大)一律直连不缓存;
|
|
10
|
+
* - install 预缓存首页作离线兜底(不另造 offline 页),skipWaiting 即刻接管;
|
|
11
|
+
* activate 清全部旧缓存并 clients.claim()。
|
|
12
|
+
* SW 文本内不使用模板字符串(避免与本文件 TS 模板嵌套转义),常量经 JSON.stringify 注入。
|
|
13
|
+
*/
|
|
14
|
+
export const GET: APIRoute = ({ site }) => {
|
|
15
|
+
const base = new URL("/", site ?? new URL("https://blog.example.com"))
|
|
16
|
+
.pathname;
|
|
17
|
+
const cacheName =
|
|
18
|
+
"orchid-pwa-" + (import.meta.env.DEV ? "dev" : Date.now().toString(36));
|
|
19
|
+
const sw = `// 由 astro-theme-orchid 构建期生成(§4.95),请勿手改
|
|
20
|
+
const CACHE = ${JSON.stringify(cacheName)};
|
|
21
|
+
const BASE = ${JSON.stringify(base)};
|
|
22
|
+
const SCOPE_MAX = 60;
|
|
23
|
+
|
|
24
|
+
self.addEventListener("install", function (event) {
|
|
25
|
+
event.waitUntil(
|
|
26
|
+
caches
|
|
27
|
+
.open(CACHE)
|
|
28
|
+
.then(function (cache) {
|
|
29
|
+
return cache.add(new Request(BASE, { cache: "reload" }));
|
|
30
|
+
})
|
|
31
|
+
.then(function () {
|
|
32
|
+
return self.skipWaiting();
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
self.addEventListener("activate", function (event) {
|
|
38
|
+
event.waitUntil(
|
|
39
|
+
caches
|
|
40
|
+
.keys()
|
|
41
|
+
.then(function (keys) {
|
|
42
|
+
return Promise.all(
|
|
43
|
+
keys
|
|
44
|
+
.filter(function (key) {
|
|
45
|
+
return key !== CACHE;
|
|
46
|
+
})
|
|
47
|
+
.map(function (key) {
|
|
48
|
+
return caches.delete(key);
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
})
|
|
52
|
+
.then(function () {
|
|
53
|
+
return self.clients.claim();
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
self.addEventListener("fetch", function (event) {
|
|
59
|
+
var req = event.request;
|
|
60
|
+
if (req.method !== "GET") return;
|
|
61
|
+
var url = new URL(req.url);
|
|
62
|
+
if (url.origin !== self.location.origin) return;
|
|
63
|
+
var path = url.pathname;
|
|
64
|
+
if (path.indexOf(BASE + "api/") === 0 || path.indexOf(BASE + "og/") === 0) return;
|
|
65
|
+
|
|
66
|
+
if (req.mode === "navigate") {
|
|
67
|
+
event.respondWith(
|
|
68
|
+
fetch(req)
|
|
69
|
+
.then(function (res) {
|
|
70
|
+
var copy = res.clone();
|
|
71
|
+
caches.open(CACHE).then(function (cache) {
|
|
72
|
+
cache.put(req, copy);
|
|
73
|
+
});
|
|
74
|
+
return res;
|
|
75
|
+
})
|
|
76
|
+
.catch(function () {
|
|
77
|
+
return caches.match(req).then(function (hit) {
|
|
78
|
+
return hit || caches.match(BASE);
|
|
79
|
+
});
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (path.indexOf(BASE + "_astro/") === 0) {
|
|
86
|
+
event.respondWith(
|
|
87
|
+
caches.match(req).then(function (hit) {
|
|
88
|
+
return hit || fetch(req);
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
event.respondWith(
|
|
95
|
+
caches.match(req).then(function (hit) {
|
|
96
|
+
var network = fetch(req)
|
|
97
|
+
.then(function (res) {
|
|
98
|
+
if (res && res.ok) {
|
|
99
|
+
var copy = res.clone();
|
|
100
|
+
caches.open(CACHE).then(function (cache) {
|
|
101
|
+
cache.put(req, copy).then(function () {
|
|
102
|
+
caches.open(CACHE).then(function (bucket) {
|
|
103
|
+
bucket.keys().then(function (keys) {
|
|
104
|
+
if (keys.length > SCOPE_MAX) bucket.delete(keys[0]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return res;
|
|
111
|
+
})
|
|
112
|
+
.catch(function () {
|
|
113
|
+
return hit;
|
|
114
|
+
});
|
|
115
|
+
if (hit) {
|
|
116
|
+
event.waitUntil(network);
|
|
117
|
+
return hit;
|
|
118
|
+
}
|
|
119
|
+
return network;
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
`;
|
|
124
|
+
return new Response(sw, {
|
|
125
|
+
headers: { "Content-Type": "text/javascript; charset=utf-8" },
|
|
126
|
+
});
|
|
127
|
+
};
|
package/src/schema.ts
CHANGED
|
@@ -108,6 +108,91 @@ export const themeConfigSchema = z.object({
|
|
|
108
108
|
quote: "",
|
|
109
109
|
sub: "",
|
|
110
110
|
}),
|
|
111
|
+
/**
|
|
112
|
+
* 全站背景壁纸(§4.92):body 级 fixed 视口背景,与顶部 banner(§4.42)相互独立。
|
|
113
|
+
* 经遮罩压暗后从页缝与卡片间隙透出;正文卡片保持不透明保证可读。
|
|
114
|
+
*/
|
|
115
|
+
background: z
|
|
116
|
+
.object({
|
|
117
|
+
/** 总开关:false 时即使配了 src 也不渲染(默认关) */
|
|
118
|
+
enabled: z.boolean().default(false),
|
|
119
|
+
/** 图源(public 路径或外链):单图 URL 或多图数组(构建期随机取一) */
|
|
120
|
+
src: z.union([z.string(), z.array(z.string())]).optional(),
|
|
121
|
+
/** 浅色模式遮罩不透明度(0-1),压暗壁纸保证正文可读 */
|
|
122
|
+
dimOpacity: z.number().min(0).max(1).default(0.45),
|
|
123
|
+
/** 暗色模式遮罩不透明度(0-1),暗色下压更暗 */
|
|
124
|
+
darkDimOpacity: z.number().min(0).max(1).default(0.65),
|
|
125
|
+
/** 壁纸模糊半径 px(0-20):模糊时自动轻微放大防止边缘露底 */
|
|
126
|
+
blur: z.number().min(0).max(20).default(0),
|
|
127
|
+
})
|
|
128
|
+
.default({
|
|
129
|
+
enabled: false,
|
|
130
|
+
dimOpacity: 0.45,
|
|
131
|
+
darkDimOpacity: 0.65,
|
|
132
|
+
blur: 0,
|
|
133
|
+
}),
|
|
134
|
+
/**
|
|
135
|
+
* 番组追番页(§4.93):构建期从 bgm.tv v0 API 拉取用户「在看」动画收藏,
|
|
136
|
+
* 渲染静态 /bangumi 页。enabled 与 userId 同时满足才注入路由(默认关)。
|
|
137
|
+
*/
|
|
138
|
+
bangumi: z
|
|
139
|
+
.object({
|
|
140
|
+
/** 总开关:与 userId 同时非空才生成 /bangumi 路由(默认关) */
|
|
141
|
+
enabled: z.boolean().default(false),
|
|
142
|
+
/** bgm.tv 用户名(https://bgm.tv/user/{username} 中的 username) */
|
|
143
|
+
userId: z.string().default(""),
|
|
144
|
+
/** 页面标题 */
|
|
145
|
+
title: z.string().default("追番"),
|
|
146
|
+
/** 页面副标题(留空不渲染) */
|
|
147
|
+
description: z.string().default(""),
|
|
148
|
+
})
|
|
149
|
+
.default({ enabled: false, userId: "", title: "追番", description: "" }),
|
|
150
|
+
/**
|
|
151
|
+
* PWA(§4.95):构建期产出 /manifest.webmanifest 与 /sw.js(手写 Service
|
|
152
|
+
* Worker,零运行时依赖),可安装 + 离线兜底。enabled=false 时两路由均不
|
|
153
|
+
* 注入,head 无 manifest 链接与注册脚本,零痕迹。
|
|
154
|
+
*/
|
|
155
|
+
pwa: z
|
|
156
|
+
.object({
|
|
157
|
+
/** 总开关:false 时不生成 manifest 与 Service Worker(默认关) */
|
|
158
|
+
enabled: z.boolean().default(false),
|
|
159
|
+
/** 安装后的应用名:留空回落站点 title */
|
|
160
|
+
name: z.string().default(""),
|
|
161
|
+
/** 安装后启动器短名:留空回落 name */
|
|
162
|
+
shortName: z.string().default(""),
|
|
163
|
+
/** 应用描述:留空回落站点 description */
|
|
164
|
+
description: z.string().default(""),
|
|
165
|
+
/** 清单 theme_color:安装 UI / 标题栏着色(独立于页面 theme-color meta) */
|
|
166
|
+
themeColor: z.string().default("#f6f4ef"),
|
|
167
|
+
/** 清单 background_color:启动画面底色 */
|
|
168
|
+
backgroundColor: z.string().default("#f6f4ef"),
|
|
169
|
+
/** 显示形态:standalone 独立应用窗口(默认)/ fullscreen / minimal-ui / browser */
|
|
170
|
+
display: z
|
|
171
|
+
.enum(["standalone", "fullscreen", "minimal-ui", "browser"])
|
|
172
|
+
.default("standalone"),
|
|
173
|
+
/** 图标清单(public 下路径):至少 192x192 + 512x512 各一张才满足可安装性 */
|
|
174
|
+
icons: z
|
|
175
|
+
.array(
|
|
176
|
+
z.object({
|
|
177
|
+
src: z.string(),
|
|
178
|
+
sizes: z.string().default("512x512"),
|
|
179
|
+
type: z.string().default("image/png"),
|
|
180
|
+
/** maskable:安装后按平台形状裁切的容错图标,可与 any 各配一张 */
|
|
181
|
+
purpose: z.enum(["any", "maskable"]).default("any"),
|
|
182
|
+
}),
|
|
183
|
+
)
|
|
184
|
+
.default([]),
|
|
185
|
+
})
|
|
186
|
+
.default({
|
|
187
|
+
enabled: false,
|
|
188
|
+
name: "",
|
|
189
|
+
shortName: "",
|
|
190
|
+
description: "",
|
|
191
|
+
themeColor: "#f6f4ef",
|
|
192
|
+
backgroundColor: "#f6f4ef",
|
|
193
|
+
display: "standalone",
|
|
194
|
+
icons: [],
|
|
195
|
+
}),
|
|
111
196
|
/**
|
|
112
197
|
* hue 调色面板(M3b-b5):导航栏调色按钮 + 预设色板 + hue 滑杆。
|
|
113
198
|
* 运行时覆写 --accent-h(s/l 保持配置值),持久化 localStorage('qy-hue')。
|
|
@@ -289,6 +374,16 @@ export const themeConfigSchema = z.object({
|
|
|
289
374
|
* 切换后以 localStorage('qy-mode') 为准。
|
|
290
375
|
*/
|
|
291
376
|
colorMode: z.enum(["light", "dark", "system"]).default("system"),
|
|
377
|
+
/**
|
|
378
|
+
* 暗色模式适配(§4.89):adaptExternalImages 开启后,暗色下对外链内容图
|
|
379
|
+
* (data-external 标记,白底截图/图表易刺眼)施加亮度滤镜压暗;
|
|
380
|
+
* 本地图与 .no-adapt 单图豁免。默认关闭保持中性。
|
|
381
|
+
*/
|
|
382
|
+
darkMode: z
|
|
383
|
+
.object({
|
|
384
|
+
adaptExternalImages: z.boolean().default(false),
|
|
385
|
+
})
|
|
386
|
+
.default({ adaptExternalImages: false }),
|
|
292
387
|
/**
|
|
293
388
|
* 代码高亮(§4.61):Expressive Code(Astro 生态标准方案)接管代码块渲染,
|
|
294
389
|
* 替代 §4.17 自研 shiki 管线与 §4.37 自研折叠。light/dark 为 EC 支持的 Shiki
|
|
@@ -760,7 +855,7 @@ export const themeConfigSchema = z.object({
|
|
|
760
855
|
displayCount: -1,
|
|
761
856
|
}),
|
|
762
857
|
/**
|
|
763
|
-
* 站点统计(P1-16):GA / Clarity / Umami / 51la
|
|
858
|
+
* 站点统计(P1-16):GA / Clarity / Umami / 51la 四选多,另配 Web Vitals 通用上报。
|
|
764
859
|
* 对应 ID 留空即不注入该统计脚本,全部留空零副作用。
|
|
765
860
|
*/
|
|
766
861
|
analytics: z
|
|
@@ -832,6 +927,19 @@ export const themeConfigSchema = z.object({
|
|
|
832
927
|
hashMode: false,
|
|
833
928
|
screenRecord: true,
|
|
834
929
|
}),
|
|
930
|
+
/** Web Vitals 通用上报(§4.91):与统计厂商无关,POST JSON 到自建端点 */
|
|
931
|
+
webVitals: z
|
|
932
|
+
.object({
|
|
933
|
+
/** 上报端点(载荷以 text/plain 载 JSON 发送,免 CORS 预检;留空不采集) */
|
|
934
|
+
endpoint: z.string().default(""),
|
|
935
|
+
/** 站点标识,随载荷一起上报(多站共用一个端点时区分来源) */
|
|
936
|
+
siteId: z.string().default(""),
|
|
937
|
+
/** 采样率 0~1,默认全量 */
|
|
938
|
+
sampleRate: z.number().min(0).max(1).default(1),
|
|
939
|
+
/** 控制台打印上报载荷(调试用) */
|
|
940
|
+
debug: z.boolean().default(false),
|
|
941
|
+
})
|
|
942
|
+
.default({ endpoint: "", siteId: "", sampleRate: 1, debug: false }),
|
|
835
943
|
})
|
|
836
944
|
.default({
|
|
837
945
|
googleAnalyticsId: "",
|
|
@@ -858,6 +966,7 @@ export const themeConfigSchema = z.object({
|
|
|
858
966
|
hashMode: false,
|
|
859
967
|
screenRecord: true,
|
|
860
968
|
},
|
|
969
|
+
webVitals: { endpoint: "", siteId: "", sampleRate: 1, debug: false },
|
|
861
970
|
}),
|
|
862
971
|
/* ---------- LaTeX 数学公式(M3b-b4) ---------- */
|
|
863
972
|
latex: z
|