@windcoder/astro-theme-orchid 0.0.0-stage → 0.1.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 +311 -2
- package/package.json +68 -3
- package/src/components/AlbumCard.astro +73 -0
- package/src/components/Analytics.astro +22 -0
- package/src/components/BaseLayout.astro +188 -0
- package/src/components/Calendar.astro +379 -0
- package/src/components/CategoryBar.astro +74 -0
- package/src/components/Comment.astro +48 -0
- package/src/components/EncryptedContent.astro +42 -0
- package/src/components/Footer.astro +31 -0
- package/src/components/Header.astro +153 -0
- package/src/components/License.astro +29 -0
- package/src/components/Live2DWidget.astro +262 -0
- package/src/components/MusicPlayer.astro +940 -0
- package/src/components/OutdatedCard.astro +53 -0
- package/src/components/Pagination.astro +94 -0
- package/src/components/PhotoCard.astro +22 -0
- package/src/components/PostCard.astro +89 -0
- package/src/components/PostNav.astro +40 -0
- package/src/components/RecommendedPost.astro +166 -0
- package/src/components/SakuraEffect.astro +152 -0
- package/src/components/Sidebar.astro +334 -0
- package/src/components/Toc.astro +27 -0
- package/src/components/analytics/GoogleAnalytics.astro +22 -0
- package/src/components/analytics/La51Analytics.astro +52 -0
- package/src/components/analytics/MicrosoftClarity.astro +18 -0
- package/src/components/analytics/UmamiAnalytics.astro +86 -0
- package/src/components/comments/Artalk.astro +51 -0
- package/src/components/comments/Disqus.astro +43 -0
- package/src/components/comments/Giscus.astro +50 -0
- package/src/components/comments/Twikoo.astro +65 -0
- package/src/components/comments/Waline.astro +36 -0
- package/src/i18n/en.ts +164 -0
- package/src/i18n/index.ts +18 -0
- package/src/i18n/resolve.ts +49 -0
- package/src/i18n/zh-CN.ts +184 -0
- package/src/icons.ts +71 -0
- package/src/index.ts +406 -0
- package/src/plugins/plantuml-encoder.ts +95 -0
- package/src/plugins/rehype-diagram.ts +218 -0
- package/src/plugins/rehype-email.ts +75 -0
- package/src/plugins/rehype-github-card.ts +114 -0
- package/src/plugins/rehype.ts +284 -0
- package/src/plugins/remark-diagram.ts +78 -0
- package/src/plugins/remark-directives.ts +100 -0
- package/src/plugins/remark-excerpt.ts +29 -0
- package/src/plugins/remark-image-grid.ts +97 -0
- package/src/plugins/remark-reading-time.ts +38 -0
- package/src/plugins/scripts/diagram-panzoom.js +388 -0
- package/src/plugins/scripts/plantuml-theme-switch.js +90 -0
- package/src/routes/404.astro +14 -0
- package/src/routes/_hydrate.ts +34 -0
- package/src/routes/about.astro +25 -0
- package/src/routes/api/allPostMeta.json.ts +26 -0
- package/src/routes/archive.astro +81 -0
- package/src/routes/categories/[category].astro +37 -0
- package/src/routes/categories/index.astro +29 -0
- package/src/routes/friends.astro +83 -0
- package/src/routes/gallery/[album].astro +97 -0
- package/src/routes/gallery/index.astro +100 -0
- package/src/routes/guestbook.astro +37 -0
- package/src/routes/index.astro +47 -0
- package/src/routes/og/[...slug].ts +281 -0
- package/src/routes/page/[page].astro +53 -0
- package/src/routes/posts/[...slug].astro +337 -0
- package/src/routes/robots.txt.ts +18 -0
- package/src/routes/rss.xml.ts +37 -0
- package/src/routes/series/[name].astro +63 -0
- package/src/routes/series/index.astro +64 -0
- package/src/routes/sponsor.astro +84 -0
- package/src/routes/tags/[tag].astro +40 -0
- package/src/routes/tags/index.astro +29 -0
- package/src/schema.ts +1212 -0
- package/src/scripts/app.ts +1041 -0
- package/src/scripts/share-poster.ts +468 -0
- package/src/styles/global.css +4830 -0
- package/src/utils/crypto.ts +46 -0
- package/src/utils/gallery.ts +76 -0
- package/src/utils/lqip.ts +18 -0
- package/src/utils/related.ts +101 -0
- package/src/utils.ts +181 -0
- package/src/vite.ts +105 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
// 字体(§4.66):Astro Fonts API 官方组件,按 cssVariable 渲染 @font-face
|
|
3
|
+
// 与变量定义(preload 开启时附带 <link rel=preload>)
|
|
4
|
+
import { Font } from "astro:assets";
|
|
5
|
+
import { ClientRouter } from "astro:transitions";
|
|
6
|
+
import Analytics from "virtual:theme/components/Analytics.astro";
|
|
7
|
+
import Footer from "virtual:theme/components/Footer.astro";
|
|
8
|
+
import Header from "virtual:theme/components/Header.astro";
|
|
9
|
+
import Live2DWidget from "virtual:theme/components/Live2DWidget.astro";
|
|
10
|
+
import MusicPlayer from "virtual:theme/components/MusicPlayer.astro";
|
|
11
|
+
import SakuraEffect from "virtual:theme/components/SakuraEffect.astro";
|
|
12
|
+
import config from "virtual:theme/config";
|
|
13
|
+
import { t } from "../i18n/index.js";
|
|
14
|
+
import { hexToHsl } from "../utils.js";
|
|
15
|
+
import "@windcoder/astro-theme-orchid/styles/global.css";
|
|
16
|
+
/* 灯箱(§4.69):PhotoSwipe 样式随页面 CSS 一起打包,JS 由 app.ts 惰性动态加载 */
|
|
17
|
+
import "photoswipe/photoswipe.css";
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
/** meta keywords(§4.19):默认站点级配置,文章页传 frontmatter tags 覆盖 */
|
|
23
|
+
keywords?: string[];
|
|
24
|
+
/** body[data-page],驱动导航高亮(home/archive/tags/post...) */
|
|
25
|
+
page?: string;
|
|
26
|
+
/** OG 分享图路径(b9.4,文章页传 /og/<slug>.png);绝对链接依赖站点 site 配置 */
|
|
27
|
+
ogImage?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
title,
|
|
32
|
+
description = config.description,
|
|
33
|
+
keywords = config.keywords,
|
|
34
|
+
page,
|
|
35
|
+
ogImage,
|
|
36
|
+
} = Astro.props;
|
|
37
|
+
const pageTitle = title ? `${title} · ${config.title}` : config.title;
|
|
38
|
+
const { h, s, l } = hexToHsl(config.accentColor);
|
|
39
|
+
/* 首页 banner(§4.42,hero+壁纸合并单配置):数组时构建期随机选一张(每页构建各自取,零 JS);
|
|
40
|
+
enabled=false 或 src 未配置不渲染图 */
|
|
41
|
+
const bannerSrc = Array.isArray(config.banner.src)
|
|
42
|
+
? config.banner.src.length
|
|
43
|
+
? config.banner.src[Math.floor(Math.random() * config.banner.src.length)]
|
|
44
|
+
: ""
|
|
45
|
+
: config.banner.src || "";
|
|
46
|
+
const showBanner =
|
|
47
|
+
config.banner.enabled &&
|
|
48
|
+
Boolean(bannerSrc) &&
|
|
49
|
+
(!config.banner.homeOnly || page === "home");
|
|
50
|
+
/* hero 文案(§4.42):quote 留空不渲染;有 banner 时叠加其上,否则渲染为列表上方独立区块 */
|
|
51
|
+
const heroEnabled = config.banner.enabled && Boolean(config.banner.quote);
|
|
52
|
+
const heroOnBanner = heroEnabled && showBanner && page === "home";
|
|
53
|
+
/* 字体角色映射(§4.66):配置了 role 的字体生成 html:root 规则,把主题三角色
|
|
54
|
+
变量重定向为 var(字体变量), 原角色回退栈(取自 global.css :root 默认值,
|
|
55
|
+
字体未覆盖的字形如中文自然回落)。html:root Specificity (0,1,1) 高于
|
|
56
|
+
:root (0,1,0),稳定覆盖不赌样式顺序;CSS 自定义属性引用是惰性求值,
|
|
57
|
+
与 <Font> 产出的变量定义先后无关 */
|
|
58
|
+
const fontRoleFallback: Record<string, string> = {
|
|
59
|
+
display: '"Noto Serif SC", "Songti SC", "STSong", "SimSun", serif',
|
|
60
|
+
body: '-apple-system, "PingFang SC", "Segoe UI", "Microsoft YaHei", sans-serif',
|
|
61
|
+
mono: 'ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", monospace',
|
|
62
|
+
};
|
|
63
|
+
const fontRoleCss = config.fonts
|
|
64
|
+
.filter((f) => f.role)
|
|
65
|
+
.map(
|
|
66
|
+
(f) =>
|
|
67
|
+
`html:root{--font-${f.role}:var(${f.cssVariable}),${fontRoleFallback[f.role]};}`,
|
|
68
|
+
)
|
|
69
|
+
.join("");
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
<!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}>
|
|
74
|
+
<head>
|
|
75
|
+
<meta charset="utf-8" />
|
|
76
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
77
|
+
<meta name="generator" content={Astro.generator} />
|
|
78
|
+
<title>{pageTitle}</title>
|
|
79
|
+
<meta name="description" content={description} />
|
|
80
|
+
{/* keywords(§4.19):非空才输出;文章页以 tags 覆盖站点级配置 */}
|
|
81
|
+
{keywords.length > 0 && <meta name="keywords" content={keywords.join(", ")} />}
|
|
82
|
+
<meta name="author" content={config.author} />
|
|
83
|
+
{/* favicon(§4.30):配置非空时按列表输出,theme 指定 light/dark 经 media 按系统深浅切换 */}
|
|
84
|
+
{config.favicon.length > 0
|
|
85
|
+
? config.favicon.map((f) => (
|
|
86
|
+
<link
|
|
87
|
+
rel="icon"
|
|
88
|
+
href={f.src}
|
|
89
|
+
type={f.type}
|
|
90
|
+
sizes={f.sizes}
|
|
91
|
+
media={f.theme ? `(prefers-color-scheme: ${f.theme})` : undefined}
|
|
92
|
+
/>
|
|
93
|
+
))
|
|
94
|
+
: <link rel="icon" href="/favicon.svg" type="image/svg+xml" />}
|
|
95
|
+
{/* SEO 头部补全(§4.12):canonical 防重复内容;RSS 供阅读器发现;theme-color 与 global.css :root 同步 */}
|
|
96
|
+
{Astro.site && <link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).href} />}
|
|
97
|
+
<link rel="alternate" type="application/rss+xml" title={config.title} href={Astro.site ? new URL("rss.xml", Astro.site).href : "/rss.xml"} />
|
|
98
|
+
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f4ef" />
|
|
99
|
+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#131516" />
|
|
100
|
+
{/* OG/Twitter meta(b9.4):ogImage 仅文章页传入;绝对链接依赖 site 配置 */}
|
|
101
|
+
<meta property="og:type" content={ogImage ? 'article' : 'website'} />
|
|
102
|
+
<meta property="og:title" content={pageTitle} />
|
|
103
|
+
<meta property="og:description" content={description} />
|
|
104
|
+
<meta property="og:site_name" content={config.title} />
|
|
105
|
+
<meta property="og:locale" content={config.lang.replace("-", "_")} />
|
|
106
|
+
{Astro.site && <meta property="og:url" content={new URL(Astro.url.pathname, Astro.site).href} />}
|
|
107
|
+
{ogImage && Astro.site && <meta property="og:image" content={new URL(ogImage, Astro.site).href} />}
|
|
108
|
+
<meta name="twitter:card" content={ogImage ? 'summary_large_image' : 'summary'} />
|
|
109
|
+
<ClientRouter />
|
|
110
|
+
{/* 字体(§4.66):Astro Fonts API 标准方案。每个 <Font> 按注入配置产出
|
|
111
|
+
@font-face + 变量定义(preload 时附带预载链接);角色映射规则随后输出 */}
|
|
112
|
+
{config.fonts.length > 0 && (
|
|
113
|
+
<>
|
|
114
|
+
{config.fonts.map((f) => (
|
|
115
|
+
<Font cssVariable={f.cssVariable} preload={f.preload} />
|
|
116
|
+
))}
|
|
117
|
+
<style is:global set:html={fontRoleCss} />
|
|
118
|
+
</>
|
|
119
|
+
)}
|
|
120
|
+
<!-- 深浅模式 boot:先于首帧执行防 FOUC;is:inline 在 VT swap 时不重复执行。
|
|
121
|
+
默认模式读 html[data-color-mode](§4.16 colorMode),用户选择以 localStorage('qy-mode') 优先 -->
|
|
122
|
+
<script is:inline>
|
|
123
|
+
(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
|
+
</script>
|
|
125
|
+
</head>
|
|
126
|
+
<!-- 客户端动态文案载体(i18n,M3b-b5):app.ts 读取,VT swap 后随新 body 重建 -->
|
|
127
|
+
<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
|
+
<body data-page={page}>
|
|
129
|
+
<a class="skip-link" href="#main">{t('skipToContent')}</a>
|
|
130
|
+
<Header />
|
|
131
|
+
<!-- 首页 banner(§4.42,hero+壁纸合并单配置):Header 之后下沉横幅 + 黑蒙层;enabled=false / src 未配置 / homeOnly 非首页不渲染 -->
|
|
132
|
+
{
|
|
133
|
+
showBanner && (
|
|
134
|
+
<div
|
|
135
|
+
class="wallpaper-banner"
|
|
136
|
+
style={`--banner-h:${config.banner.height}vh;--banner-dim:${config.banner.dimOpacity};--banner-pos:${config.banner.position}`}
|
|
137
|
+
>
|
|
138
|
+
<img src={bannerSrc} alt="" aria-hidden="true" loading="eager" />
|
|
139
|
+
<div class="wallpaper-dim" aria-hidden="true" />
|
|
140
|
+
{/* hero 文案叠加(§4.42):quote 配置时主句/副句居中显示在 banner 上 */}
|
|
141
|
+
{heroOnBanner && (
|
|
142
|
+
<div class="wallpaper-hero reveal">
|
|
143
|
+
<h1 class="hero-quote" set:html={config.banner.quote} />
|
|
144
|
+
{config.banner.sub && <p class="hero-sub">{config.banner.sub}</p>}
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
<main id="main" class="site-main">
|
|
151
|
+
<div class="shell">
|
|
152
|
+
{/* hero 独立区块形态(§4.42):无 banner 图时的首页 hero 文案,保持原列表上方位置 */}
|
|
153
|
+
{
|
|
154
|
+
heroEnabled && page === "home" && !heroOnBanner && (
|
|
155
|
+
<section class="hero reveal">
|
|
156
|
+
<h1 class="hero-quote" set:html={config.banner.quote} />
|
|
157
|
+
{config.banner.sub && <p class="hero-sub">{config.banner.sub}</p>}
|
|
158
|
+
</section>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
<slot />
|
|
162
|
+
</div>
|
|
163
|
+
</main>
|
|
164
|
+
<!-- 列表形态恢复(§4.34→§4.55):首帧绘制前按 localStorage > defaultLayout 应用防闪烁;
|
|
165
|
+
is:inline 在 VT 换页不重执行,跨页返回由 app.ts initPage 接管 -->
|
|
166
|
+
<script is:inline>
|
|
167
|
+
(function(){try{var l=document.getElementById('postList');var m=localStorage.getItem('qy-postList')||(l&&l.dataset.defaultLayout)||'list';if(m!=='grid'||!l)return;l.classList.add('grid-mode');l.style.setProperty('--col-w',(l.dataset.gridCol||'320')+'px');var b=document.getElementById('layoutSwitch');if(b)b.setAttribute('aria-pressed','true');}catch(e){}})();
|
|
168
|
+
</script>
|
|
169
|
+
<Footer />
|
|
170
|
+
<!-- 站点统计(P1-16):ID 全空零输出,挂 body 尾与 orchid-blog 口径一致 -->
|
|
171
|
+
<Analytics />
|
|
172
|
+
<!-- 樱花飘落特效(§4.30):sakura.enabled 开启才渲染 -->
|
|
173
|
+
{config.sakura.enabled && <SakuraEffect density={config.sakura.density} />}
|
|
174
|
+
<!-- 悬浮音乐播放器(§4.36):music.enabled 且歌单非空才渲染 -->
|
|
175
|
+
{config.music.enabled && config.music.playlist.length > 0 && (
|
|
176
|
+
<MusicPlayer music={config.music} />
|
|
177
|
+
)}
|
|
178
|
+
<!-- Live2D 看板娘(§4.38):live2d.enabled 且配置了 model 才渲染 -->
|
|
179
|
+
{config.live2d.enabled && config.live2d.model && <Live2DWidget live2d={config.live2d} />}
|
|
180
|
+
<button class="back-top" id="backTop" type="button" aria-label={t('backToTop')} hidden>
|
|
181
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m18 15-6-6-6 6"/></svg>
|
|
182
|
+
</button>
|
|
183
|
+
<!-- 全局交互(VT 兼容:document 级委托 + astro:page-load 重初始化) -->
|
|
184
|
+
<script>
|
|
185
|
+
import '../scripts/app';
|
|
186
|
+
</script>
|
|
187
|
+
</body>
|
|
188
|
+
</html>
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* 侧栏日历 + 活跃度热力图(P2 侧栏改造)。
|
|
4
|
+
* 月视图(周一起始,当月有文章日高亮)+ 近 12 个月热力图(每月 4 段,透明度 5 档):
|
|
5
|
+
* 顶部月份标签(§4.43),整列可点击跳转对应月份,单元悬停提示月份第几周 · 几篇。
|
|
6
|
+
* 年月标签可点击展开年月选择器(§4.47:年步进 + 12 月网格、未来月置灰),非当月时头部出现「回到本月」按钮(§4.54 移至日期 ▾ 之后)。
|
|
7
|
+
* 数据复用 /api/allPostMeta.json(加密文章已在端点侧排除)。
|
|
8
|
+
* 热力图下方文章列表(§4.40,§4.43 移至热力图下):默认当月全部,点选有文章日期过滤当日,再点取消;
|
|
9
|
+
* 按时间倒序,左侧标题右侧日期;条目悬停全文 title 可经 widgets 数组 calendar 项 specificConfig.postTooltip 开启(§4.41),默认关。
|
|
10
|
+
* <qy-calendar> 为 VT 安全 custom element:is:inline + customElements.get 防重复 define,
|
|
11
|
+
* 元数据缓存在 window.__qyAllPostMetaCache(与 random-posts 共用)。
|
|
12
|
+
*/
|
|
13
|
+
import { t } from "../i18n/index.js";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** 站点语言(决定月份/星期文案的 locale) */
|
|
17
|
+
lang?: string;
|
|
18
|
+
/** 日历下方文章列表条目悬停显示全文 title(§4.41);默认关 */
|
|
19
|
+
postTooltip?: boolean;
|
|
20
|
+
/** 移动端(<768px)隐藏整个 widget(§4.49);默认否 */
|
|
21
|
+
hideMobile?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { lang = "zh-CN", postTooltip = false, hideMobile = false } = Astro.props;
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<section class:list={["side-card", "card", "reveal", "calendar-widget", { "side-hide-m": hideMobile }]}>
|
|
28
|
+
<!-- §4.47:「日历」标题移除,年月标签即卡片头部(加粗、可点选年月) -->
|
|
29
|
+
<qy-calendar
|
|
30
|
+
data-lang={lang}
|
|
31
|
+
data-api-url="/api/allPostMeta.json"
|
|
32
|
+
data-label-posts={t('dayPostsCount')}
|
|
33
|
+
data-label-heat-week={t('heatWeekTip')}
|
|
34
|
+
data-label-home={t('calendarHome')}
|
|
35
|
+
data-label-pick={t('calendarPick')}
|
|
36
|
+
data-post-tooltip={postTooltip ? '1' : '0'}
|
|
37
|
+
></qy-calendar>
|
|
38
|
+
</section>
|
|
39
|
+
|
|
40
|
+
<script is:inline>
|
|
41
|
+
if (!customElements.get('qy-calendar')) {
|
|
42
|
+
customElements.define(
|
|
43
|
+
'qy-calendar',
|
|
44
|
+
class extends HTMLElement {
|
|
45
|
+
connectedCallback() {
|
|
46
|
+
// 每次 connected 重建月视图:VT 往返后回到当月是期望行为
|
|
47
|
+
this.render();
|
|
48
|
+
}
|
|
49
|
+
getMeta() {
|
|
50
|
+
if (window.__qyAllPostMetaCache) {
|
|
51
|
+
return Promise.resolve(window.__qyAllPostMetaCache);
|
|
52
|
+
}
|
|
53
|
+
return fetch(this.dataset.apiUrl)
|
|
54
|
+
.then((r) => r.json())
|
|
55
|
+
.then((data) => {
|
|
56
|
+
window.__qyAllPostMetaCache = data;
|
|
57
|
+
return data;
|
|
58
|
+
})
|
|
59
|
+
.catch(() => null);
|
|
60
|
+
}
|
|
61
|
+
render() {
|
|
62
|
+
var self = this;
|
|
63
|
+
this.getMeta().then(function (meta) {
|
|
64
|
+
if (!meta) return;
|
|
65
|
+
// key: YYYY-MM-DD(本地时区) → 当日文章数
|
|
66
|
+
var byDay = {};
|
|
67
|
+
meta.forEach(function (p) {
|
|
68
|
+
var d = new Date(p.published);
|
|
69
|
+
var key =
|
|
70
|
+
d.getFullYear() +
|
|
71
|
+
'-' +
|
|
72
|
+
String(d.getMonth() + 1).padStart(2, '0') +
|
|
73
|
+
'-' +
|
|
74
|
+
String(d.getDate()).padStart(2, '0');
|
|
75
|
+
byDay[key] = (byDay[key] || 0) + 1;
|
|
76
|
+
});
|
|
77
|
+
var locale = self.dataset.lang || 'zh-CN';
|
|
78
|
+
var postsLabel = self.dataset.labelPosts || '{n}';
|
|
79
|
+
// 热力图单元悬停模板(§4.43):{m}=月份短文案、{w}=第几周、{n}=篇数
|
|
80
|
+
var weekTip = self.dataset.labelHeatWeek || '{m}第{w}周 · {n}';
|
|
81
|
+
// 「回到本月」按钮与年月标签按钮的 title/aria 文案(§4.47)
|
|
82
|
+
var homeLabel = self.dataset.labelHome || '';
|
|
83
|
+
var pickLabel = self.dataset.labelPick || '';
|
|
84
|
+
var today = new Date();
|
|
85
|
+
var thisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
86
|
+
var cur = new Date(thisMonth);
|
|
87
|
+
// 年月选择器年份下界(§4.47):数据最早文章年份,无文章则为当年(须在 today 声明后)
|
|
88
|
+
var minYear = today.getFullYear();
|
|
89
|
+
meta.forEach(function (p) {
|
|
90
|
+
var y = new Date(p.published).getFullYear();
|
|
91
|
+
if (y < minYear) minYear = y;
|
|
92
|
+
});
|
|
93
|
+
var fmtMonth = new Intl.DateTimeFormat(locale, {
|
|
94
|
+
year: 'numeric',
|
|
95
|
+
month: 'long',
|
|
96
|
+
});
|
|
97
|
+
var fmtWeek = new Intl.DateTimeFormat(locale, { weekday: 'narrow' });
|
|
98
|
+
// 热力图悬停月份短文案(§4.43):zh="10月"、en="Oct"
|
|
99
|
+
var fmtMonthShort = new Intl.DateTimeFormat(locale, { month: 'short' });
|
|
100
|
+
// 周一起始的星期表头(2024-01-01 恰好是周一)
|
|
101
|
+
var weekHeads = [];
|
|
102
|
+
for (var i = 0; i < 7; i++) {
|
|
103
|
+
weekHeads.push(fmtWeek.format(new Date(2024, 0, 1 + i)));
|
|
104
|
+
}
|
|
105
|
+
var tooltip = function (key, n) {
|
|
106
|
+
return key + ' · ' + postsLabel.replace('{n}', n);
|
|
107
|
+
};
|
|
108
|
+
// 与 byDay 一致的 YYYY-MM-DD key(本地时区),列表过滤用
|
|
109
|
+
var dayKey = function (p) {
|
|
110
|
+
var d = new Date(p.published);
|
|
111
|
+
return (
|
|
112
|
+
d.getFullYear() +
|
|
113
|
+
'-' +
|
|
114
|
+
String(d.getMonth() + 1).padStart(2, '0') +
|
|
115
|
+
'-' +
|
|
116
|
+
String(d.getDate()).padStart(2, '0')
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
var esc = function (s) {
|
|
120
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
121
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
// 列表条目日期(§4.43):两位月/日,如 10/08
|
|
125
|
+
var fmtItemDate = new Intl.DateTimeFormat(locale, { month: '2-digit', day: '2-digit' });
|
|
126
|
+
// 当前选中的日期 key(null = 未选中,列表显示当月全部)
|
|
127
|
+
var selKey = null;
|
|
128
|
+
// 文章列表条目悬停显示全文 title(§4.41),默认关
|
|
129
|
+
var postTip = self.dataset.postTooltip === '1';
|
|
130
|
+
// 年月选择器状态(§4.47):展开态 + 选择器年份(打开时同步为当前浏览年份)
|
|
131
|
+
var pickOpen = false;
|
|
132
|
+
var pickYear = today.getFullYear();
|
|
133
|
+
|
|
134
|
+
// 年月选择器(§4.47):年步进行(minYear~今年,边界置灰)+ 12 月网格(未来月置灰、当前浏览月高亮)
|
|
135
|
+
var pickerHtml = function () {
|
|
136
|
+
if (!pickOpen) return '';
|
|
137
|
+
var cy = cur.getFullYear();
|
|
138
|
+
var cm = cur.getMonth();
|
|
139
|
+
var thisYear = today.getFullYear();
|
|
140
|
+
var html =
|
|
141
|
+
'<div class="cal-picker" role="group" aria-label="' + esc(pickLabel) + '">' +
|
|
142
|
+
'<div class="cal-picker-row">' +
|
|
143
|
+
'<button type="button" class="cal-py" data-dir="-1" aria-label="prev year"' +
|
|
144
|
+
(pickYear <= minYear ? ' disabled' : '') +
|
|
145
|
+
'>‹</button>' +
|
|
146
|
+
'<b class="cal-py-label">' + pickYear + '</b>' +
|
|
147
|
+
'<button type="button" class="cal-py" data-dir="1" aria-label="next year"' +
|
|
148
|
+
(pickYear >= thisYear ? ' disabled' : '') +
|
|
149
|
+
'>›</button>' +
|
|
150
|
+
'</div><div class="cal-picker-months">';
|
|
151
|
+
for (var m = 0; m < 12; m++) {
|
|
152
|
+
var d = new Date(pickYear, m, 1);
|
|
153
|
+
var future = d > thisMonth;
|
|
154
|
+
var on = pickYear === cy && m === cm;
|
|
155
|
+
html +=
|
|
156
|
+
'<button type="button" class="cal-pm' + (on ? ' on' : '') +
|
|
157
|
+
'" data-m="' + m + '"' + (future ? ' disabled' : '') + '>' +
|
|
158
|
+
fmtMonthShort.format(d) + '</button>';
|
|
159
|
+
}
|
|
160
|
+
return html + '</div></div>';
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
var monthHtml = function () {
|
|
164
|
+
var y = cur.getFullYear();
|
|
165
|
+
var m = cur.getMonth();
|
|
166
|
+
var lead = (new Date(y, m, 1).getDay() + 6) % 7;
|
|
167
|
+
var days = new Date(y, m + 1, 0).getDate();
|
|
168
|
+
// 是否当前月(§4.47):非当月才渲染「回到本月」按钮
|
|
169
|
+
var isThisMonth = y === thisMonth.getFullYear() && m === thisMonth.getMonth();
|
|
170
|
+
var html =
|
|
171
|
+
'<div class="cal-head">' +
|
|
172
|
+
'<button type="button" class="cal-nav" data-dir="-1" aria-label="prev">‹</button>' +
|
|
173
|
+
// §4.54 日期与「回到本月」成组:重置按钮紧跟日期 ▾ 之后
|
|
174
|
+
'<span class="cal-head-c">' +
|
|
175
|
+
// 年月标签改为可点按钮(§4.47):点击开合年月选择器
|
|
176
|
+
'<button type="button" class="cal-month' + (pickOpen ? ' open' : '') +
|
|
177
|
+
'" aria-expanded="' + pickOpen + '" title="' + esc(pickLabel) + '">' +
|
|
178
|
+
fmtMonth.format(cur) + '</button>' +
|
|
179
|
+
(isThisMonth
|
|
180
|
+
? ''
|
|
181
|
+
: '<button type="button" class="cal-home" title="' + esc(homeLabel) +
|
|
182
|
+
'" aria-label="' + esc(homeLabel) + '">↺</button>') +
|
|
183
|
+
'</span>' +
|
|
184
|
+
'<span class="cal-head-r">' +
|
|
185
|
+
'<button type="button" class="cal-nav" data-dir="1" aria-label="next">›</button>' +
|
|
186
|
+
'</span>' +
|
|
187
|
+
'</div>' +
|
|
188
|
+
pickerHtml() +
|
|
189
|
+
'<div class="cal-grid">';
|
|
190
|
+
for (var i = 0; i < 7; i++) {
|
|
191
|
+
html += '<span class="cal-week">' + weekHeads[i] + '</span>';
|
|
192
|
+
}
|
|
193
|
+
for (var i = 0; i < lead; i++) {
|
|
194
|
+
html += '<span class="cal-day dim"></span>';
|
|
195
|
+
}
|
|
196
|
+
for (var day = 1; day <= days; day++) {
|
|
197
|
+
var key =
|
|
198
|
+
y + '-' + String(m + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0');
|
|
199
|
+
var n = byDay[key] || 0;
|
|
200
|
+
var cls = 'cal-day';
|
|
201
|
+
if (n > 0) cls += ' has-post';
|
|
202
|
+
if (selKey && key === selKey) cls += ' sel';
|
|
203
|
+
if (y === today.getFullYear() && m === today.getMonth() && day === today.getDate()) {
|
|
204
|
+
cls += ' today';
|
|
205
|
+
}
|
|
206
|
+
html +=
|
|
207
|
+
'<span class="' + cls + '"' +
|
|
208
|
+
(n > 0
|
|
209
|
+
? ' data-key="' + key + '" role="button" tabindex="0" title="' + tooltip(key, n) + '"'
|
|
210
|
+
: '') +
|
|
211
|
+
'>' + day + '</span>';
|
|
212
|
+
}
|
|
213
|
+
return html + '</div>';
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// 热力图下方文章列表(§4.40,§4.43 移至热力图下,§4.43 修正去统计标题):默认当月全部,
|
|
217
|
+
// 选中日期则只列当日;按时间倒序,左侧标题右侧日期,超宽标题省略号截断
|
|
218
|
+
var dayListHtml = function () {
|
|
219
|
+
var ym = cur.getFullYear() + '-' + String(cur.getMonth() + 1).padStart(2, '0');
|
|
220
|
+
var list = meta.filter(function (p) {
|
|
221
|
+
if (selKey) return dayKey(p) === selKey;
|
|
222
|
+
return dayKey(p).slice(0, 7) === ym;
|
|
223
|
+
});
|
|
224
|
+
// 时间倒序(filter 返回新数组,原地 sort 安全)
|
|
225
|
+
list.sort(function (a, b) {
|
|
226
|
+
return new Date(b.published) - new Date(a.published);
|
|
227
|
+
});
|
|
228
|
+
var html = '<div class="cal-list">';
|
|
229
|
+
if (list.length) {
|
|
230
|
+
html += '<div class="cal-list-items">';
|
|
231
|
+
list.forEach(function (p) {
|
|
232
|
+
var d = new Date(p.published);
|
|
233
|
+
html +=
|
|
234
|
+
'<a class="cal-post" href="/posts/' + p.id + '/"' +
|
|
235
|
+
(postTip ? ' title="' + esc(p.title) + '"' : '') +
|
|
236
|
+
'><span class="cal-post-title">' + esc(p.title) + '</span>' +
|
|
237
|
+
'<time class="cal-post-date" datetime="' + dayKey(p) + '">' +
|
|
238
|
+
fmtItemDate.format(d) + '</time></a>';
|
|
239
|
+
});
|
|
240
|
+
html += '</div>';
|
|
241
|
+
}
|
|
242
|
+
return html + '</div>';
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// 今年 1-12 月热力图(§4.44,原近 12 个月):还原 orchid-blog 原版形态——
|
|
246
|
+
// 月份标签独立一行(grid 12 列),下方 48 个正方形小格子平铺 12×4;
|
|
247
|
+
// 透明度 5 档 0 / .45 / .65 / .85 / 1,格子悬停提示月份第几周 · 几篇、点击跳转对应月份;
|
|
248
|
+
// 未来月份格子不渲染为按钮(月视图不允许翻到未来),仅作占位展示
|
|
249
|
+
var heatHtml = function () {
|
|
250
|
+
var y = today.getFullYear();
|
|
251
|
+
var labels = '';
|
|
252
|
+
var cells = '';
|
|
253
|
+
for (var m = 0; m < 12; m++) {
|
|
254
|
+
var d = new Date(y, m, 1);
|
|
255
|
+
var days = new Date(y, m + 1, 0).getDate();
|
|
256
|
+
var buckets = [0, 0, 0, 0];
|
|
257
|
+
for (var day = 1; day <= days; day++) {
|
|
258
|
+
var w = Math.min(Math.floor((day - 1) / 7), 3);
|
|
259
|
+
var key =
|
|
260
|
+
y + '-' + String(m + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0');
|
|
261
|
+
buckets[w] += byDay[key] || 0;
|
|
262
|
+
}
|
|
263
|
+
var mShort = fmtMonthShort.format(d);
|
|
264
|
+
var future = d > thisMonth;
|
|
265
|
+
labels += '<b class="cal-heat-label">' + (m + 1) + '</b>';
|
|
266
|
+
for (var b = 0; b < 4; b++) {
|
|
267
|
+
var n = buckets[b];
|
|
268
|
+
var o = n === 0 ? 0 : n < 3 ? 0.45 : n < 6 ? 0.65 : n < 10 ? 0.85 : 1;
|
|
269
|
+
cells +=
|
|
270
|
+
'<i class="cal-heat-cell" data-y="' +
|
|
271
|
+
y +
|
|
272
|
+
'" data-m="' +
|
|
273
|
+
m +
|
|
274
|
+
'" style="--o:' +
|
|
275
|
+
o +
|
|
276
|
+
'"' +
|
|
277
|
+
(future ? '' : ' role="button" tabindex="0"') +
|
|
278
|
+
' title="' +
|
|
279
|
+
esc(weekTip.replace('{m}', mShort).replace('{w}', b + 1).replace('{n}', n)) +
|
|
280
|
+
'"></i>';
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return (
|
|
284
|
+
'<div class="cal-heat-labels">' +
|
|
285
|
+
labels +
|
|
286
|
+
'</div><div class="cal-heat-grid">' +
|
|
287
|
+
cells +
|
|
288
|
+
'</div>'
|
|
289
|
+
);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
var build = function () {
|
|
293
|
+
// 渲染顺序(§4.43):月视图 → 热力图 → 文章列表
|
|
294
|
+
self.innerHTML =
|
|
295
|
+
monthHtml() + '<div class="cal-heat">' + heatHtml() + '</div>' + dayListHtml();
|
|
296
|
+
self.querySelectorAll('.cal-nav[data-dir]').forEach(function (btn) {
|
|
297
|
+
btn.addEventListener('click', function () {
|
|
298
|
+
var dir = parseInt(btn.dataset.dir, 10);
|
|
299
|
+
var next = new Date(cur.getFullYear(), cur.getMonth() + dir, 1);
|
|
300
|
+
// 不允许翻到未来月份
|
|
301
|
+
if (next > thisMonth) return;
|
|
302
|
+
cur = next;
|
|
303
|
+
selKey = null; // 翻月取消日期选中,列表回到当月全部
|
|
304
|
+
build();
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
// 年月标签开合选择器(§4.47):打开时同步选择器年份为当前浏览年份
|
|
308
|
+
var monthBtn = self.querySelector('.cal-month');
|
|
309
|
+
if (monthBtn) {
|
|
310
|
+
monthBtn.addEventListener('click', function () {
|
|
311
|
+
pickOpen = !pickOpen;
|
|
312
|
+
if (pickOpen) pickYear = cur.getFullYear();
|
|
313
|
+
build();
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// 回到本月(§4.47):仅非当月渲染;同时收起选择器、取消日期选中
|
|
317
|
+
var homeBtn = self.querySelector('.cal-home');
|
|
318
|
+
if (homeBtn) {
|
|
319
|
+
homeBtn.addEventListener('click', function () {
|
|
320
|
+
cur = new Date(thisMonth.getFullYear(), thisMonth.getMonth(), 1);
|
|
321
|
+
selKey = null;
|
|
322
|
+
pickOpen = false;
|
|
323
|
+
build();
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
// 选择器年步进(§4.47):边界按钮已置灰,无需额外钳制
|
|
327
|
+
self.querySelectorAll('.cal-py').forEach(function (btn) {
|
|
328
|
+
btn.addEventListener('click', function () {
|
|
329
|
+
pickYear += parseInt(btn.dataset.dir, 10);
|
|
330
|
+
build();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
// 选择器月份点击(§4.47):跳月并收起选择器
|
|
334
|
+
self.querySelectorAll('.cal-pm:not([disabled])').forEach(function (btn) {
|
|
335
|
+
btn.addEventListener('click', function () {
|
|
336
|
+
cur = new Date(pickYear, parseInt(btn.dataset.m, 10), 1);
|
|
337
|
+
selKey = null;
|
|
338
|
+
pickOpen = false;
|
|
339
|
+
build();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
// 点热力图格子(§4.43 整列点击,§4.44 还原原版改格子级点击):跳转对应月份,列表跟随;
|
|
343
|
+
// 未来月份格子无 role 不绑(月视图不允许翻到未来);Enter/Space 同点按
|
|
344
|
+
self.querySelectorAll('.cal-heat-cell[role="button"]').forEach(function (el) {
|
|
345
|
+
var jump = function () {
|
|
346
|
+
cur = new Date(+el.dataset.y, +el.dataset.m, 1);
|
|
347
|
+
selKey = null;
|
|
348
|
+
build();
|
|
349
|
+
};
|
|
350
|
+
el.addEventListener('click', jump);
|
|
351
|
+
el.addEventListener('keydown', function (e) {
|
|
352
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
353
|
+
e.preventDefault();
|
|
354
|
+
jump();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
// 点日期(§4.40):toggle 选中,列表在当日/当月间切换;Enter/Space 同点按
|
|
359
|
+
self.querySelectorAll('.cal-day.has-post').forEach(function (el) {
|
|
360
|
+
var toggle = function () {
|
|
361
|
+
selKey = selKey === el.dataset.key ? null : el.dataset.key;
|
|
362
|
+
build();
|
|
363
|
+
};
|
|
364
|
+
el.addEventListener('click', toggle);
|
|
365
|
+
el.addEventListener('keydown', function (e) {
|
|
366
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
367
|
+
e.preventDefault();
|
|
368
|
+
toggle();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
};
|
|
373
|
+
build();
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
</script>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* 分类导航栏(§4.15,参考 orchid-blog CategoryBar;§4.21 改版):
|
|
4
|
+
* 仅首页渲染(index.astro post-list 顶部,与置顶/近期文章同列宽),
|
|
5
|
+
* 结构 = 首页 pill + 归档 pill + 分隔线 + 分类滚动区(pill 带计数,溢出横向滚动)
|
|
6
|
+
* + 分隔线 + 「更多」固定链接跳 /categories/ 分类总览页(始终显示)。
|
|
7
|
+
* 组件内自行聚合分类(仅取分类名与数量,不做 hydrate);
|
|
8
|
+
* 激活态在构建期由 Astro.url.pathname 判断(SSG 每页 pathname 可用),零客户端 JS。
|
|
9
|
+
* 溢出处理(§4.21,替代 §4.18 折叠下拉):溢出时整条横向滚动、右缘渐隐提示(app.ts
|
|
10
|
+
* 检测 scrollWidth 溢出后给 .category-bar 加 .is-scrollable),全量分类见「更多」页。
|
|
11
|
+
*/
|
|
12
|
+
import { getCollection } from "astro:content";
|
|
13
|
+
import config from "virtual:theme/config";
|
|
14
|
+
import { t } from "../i18n/index.js";
|
|
15
|
+
import { collectCategories } from "../utils.js";
|
|
16
|
+
|
|
17
|
+
const posts = await getCollection("posts", ({ data }) =>
|
|
18
|
+
import.meta.env.PROD ? !data.draft : true,
|
|
19
|
+
);
|
|
20
|
+
const cats = [...collectCategories(posts).entries()];
|
|
21
|
+
|
|
22
|
+
// 统一去尾斜杠后比较(dev 与 prerender 的 pathname 尾斜杠行为一致化)
|
|
23
|
+
const path = Astro.url.pathname.replace(/\/+$/, "");
|
|
24
|
+
const isHome = path === "";
|
|
25
|
+
const isArchive = path === "/archive";
|
|
26
|
+
const isCategory = (name: string) =>
|
|
27
|
+
path === `/categories/${encodeURIComponent(name)}`;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
config.categoryBar.enabled && (
|
|
32
|
+
<nav class="category-bar card reveal" aria-label={t("categoryNav")}>
|
|
33
|
+
<a class:list={["category-pill", { "is-active": isHome }]} href="/">
|
|
34
|
+
{t("home")}
|
|
35
|
+
</a>
|
|
36
|
+
<a
|
|
37
|
+
class:list={["category-pill", { "is-active": isArchive }]}
|
|
38
|
+
href="/archive"
|
|
39
|
+
>
|
|
40
|
+
{t("archive")}
|
|
41
|
+
</a>
|
|
42
|
+
<span class="bar-divider" aria-hidden="true" />
|
|
43
|
+
<div class="bar-scroll">
|
|
44
|
+
{cats.map(([name, list]) => (
|
|
45
|
+
<a
|
|
46
|
+
class:list={["category-pill", { "is-active": isCategory(name) }]}
|
|
47
|
+
href={`/categories/${encodeURIComponent(name)}/`}
|
|
48
|
+
>
|
|
49
|
+
{name}
|
|
50
|
+
<span class="pill-count">{list.length}</span>
|
|
51
|
+
</a>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
{/* 「更多」固定跳分类总览页(§4.21),始终显示 */}
|
|
55
|
+
<span class="bar-divider" aria-hidden="true" />
|
|
56
|
+
<a class="category-pill bar-more" href="/categories/">
|
|
57
|
+
{t("more")}
|
|
58
|
+
<svg
|
|
59
|
+
class="bar-more-arrow"
|
|
60
|
+
viewBox="0 0 24 24"
|
|
61
|
+
fill="none"
|
|
62
|
+
stroke="currentColor"
|
|
63
|
+
stroke-width="1.75"
|
|
64
|
+
stroke-linecap="round"
|
|
65
|
+
stroke-linejoin="round"
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
>
|
|
68
|
+
<path d="M5 12h14" />
|
|
69
|
+
<path d="m12 5 7 7-7 7" />
|
|
70
|
+
</svg>
|
|
71
|
+
</a>
|
|
72
|
+
</nav>
|
|
73
|
+
)
|
|
74
|
+
}
|