feat(geek): 极客层 P0 四件套——键盘/构建透明/页面转场/项目墙
- Keyboard:? 帮助浮层 + t 明暗 + g-chords 导航(300ms 缓冲,输入域旁路);
路由表经 data-routes 注入(module 脚本 currentScript 为 null 的坑)
- BuildInfo:vite.define 四重回退注入 commit/时间,hash 链到 gitea
- ClientRouter 页面转场(reduced-motion 降级在 global.css 总闸)
- ProjectWall:构建时双源拉取(gitea+github)置顶排序+缓存兜底,零运行时 JS
- Header 补主题可视切换钮:键盘 t 的触屏等价物(同 key 同逻辑)
验证: pnpm astro build exit 0;dist 标记 built from×2/kbd-help×4/
view-transition×2; astro check 未跑(无类型错误构建期可证伪面有限)
Spec: 2026-10-24_add-blog-skeleton
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
b50eddf2bb
commit
725bfde21f
8 files changed
+324
-2
No files matched your search
+18
-1
@@ -1,7 +1,24 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
// 构建透明(T7):commit/时间经 vite.define 注入,页脚与 /about 引用。
|
||||||
|
// 四重回退:CI 环境变量优先(GITEA_ACTIONS/GITHUB_SHA)→ 本地 git → dev 占位。
|
||||||
|
const sha =
|
||||||
|
process.env.GITEA_COMMIT_SHA ||
|
||||||
|
process.env.GITHUB_SHA ||
|
||||||
|
(() => { try { return execSync('git rev-parse HEAD').toString().trim(); } catch { return 'dev'; } })();
|
||||||
|
const date =
|
||||||
|
process.env.GITEA_COMMIT_DATE ||
|
||||||
|
process.env.GITHUB_COMMIT_DATE ||
|
||||||
|
(() => { try { return execSync('git log -1 --format=%cI').toString().trim(); } catch { return new Date().toISOString(); } })();
|
||||||
|
|
||||||
// 极简起点:集成(pagefind/rss/sitemap)在 T8 按任务加入,保持骨架可构建
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://wait4x.online',
|
site: 'https://wait4x.online',
|
||||||
|
vite: {
|
||||||
|
define: {
|
||||||
|
'import.meta.env.GIT_COMMIT_SHA': JSON.stringify(sha),
|
||||||
|
'import.meta.env.GIT_COMMIT_DATE': JSON.stringify(date),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -35,11 +35,30 @@ const isCurrent = (href: string) =>
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<!-- 触屏等价物:主题可视切换(与键盘 t / ThemeScript 同一存储 key) -->
|
||||||
|
<li>
|
||||||
|
<button class="tap nav-link" type="button" data-theme-toggle aria-label="切换明暗">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="4"></circle>
|
||||||
|
<path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 与 geek/Keyboard.ts 的 t 键同一逻辑与 key,双入口不改行为
|
||||||
|
document.querySelector('[data-theme-toggle]')?.addEventListener('click', () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
root.dataset.theme = next;
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.site-header {
|
.site-header {
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
// 构建透明(极客层 P0):页脚 built-from 行,hash 链到自家 gitea commit
|
||||||
|
const sha = import.meta.env.GIT_COMMIT_SHA as string;
|
||||||
|
const date = import.meta.env.GIT_COMMIT_DATE as string;
|
||||||
|
const short = sha === 'dev' ? 'dev' : sha.slice(0, 7);
|
||||||
|
const commitUrl = `https://git.wait4x.online/wait4x/blog/commit/${sha}`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<p class="build-info">
|
||||||
|
built from
|
||||||
|
{short === 'dev' ? <span>dev</span> : <a href={commitUrl} class="link mono-link">{short}</a>}
|
||||||
|
· {new Date(date).toISOString().slice(0, 16).replace('T', ' ')}Z · Astro
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
// 键盘层(极客层 P0):? 帮助 / t 主题 / g-chords 导航
|
||||||
|
// 惯例来源:GitHub 键位(g+N、? 帮助);每项能力都有可视入口(触屏等价物原则)
|
||||||
|
const routes: Record<string, string> = {
|
||||||
|
h: '/',
|
||||||
|
a: '/archives/',
|
||||||
|
g: '/gallery/',
|
||||||
|
m: '/moments/',
|
||||||
|
b: '/about/',
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<dialog id="kbd-help" class="kbd-help" data-routes={JSON.stringify(routes)}>
|
||||||
|
<h2>键盘快捷键</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><kbd>t</kbd></td><td>切换明暗</td></tr>
|
||||||
|
<tr><td><kbd>?</kbd></td><td>本帮助</td></tr>
|
||||||
|
<tr><td><kbd>g</kbd> <kbd>h</kbd></td><td>首页</td></tr>
|
||||||
|
<tr><td><kbd>g</kbd> <kbd>a</kbd></td><td>文章</td></tr>
|
||||||
|
<tr><td><kbd>g</kbd> <kbd>g</kbd></td><td>图库</td></tr>
|
||||||
|
<tr><td><kbd>g</kbd> <kbd>m</kbd></td><td>瞬间</td></tr>
|
||||||
|
<tr><td><kbd>Esc</kbd></td><td>关闭浮层</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="hint">提示:试试终端彩蛋(Phase 4 上线 <code>~</code>)</p>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// chord 缓冲 300ms;输入域聚焦时整体旁路;Esc 关一切浮层
|
||||||
|
// 路由表由 data-routes 注入(module 脚本里 currentScript 恒为 null,不能走内联取参)
|
||||||
|
const helpEl = document.getElementById('kbd-help') as HTMLElement & { dataset: { routes?: string } };
|
||||||
|
const routes: Record<string, string> = JSON.parse(helpEl?.dataset.routes ?? '{}');
|
||||||
|
let chord = '';
|
||||||
|
let chordTimer: number | undefined;
|
||||||
|
|
||||||
|
const inField = () => {
|
||||||
|
const el = document.activeElement;
|
||||||
|
return el instanceof HTMLElement &&
|
||||||
|
(el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
root.dataset.theme = next;
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.querySelector('dialog[open]')?.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inField() || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
|
||||||
|
if (e.key === 't') { toggleTheme(); return; }
|
||||||
|
if (e.key === '?') {
|
||||||
|
(document.getElementById('kbd-help') as HTMLDialogElement | null)?.showModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'g') {
|
||||||
|
chord = 'g';
|
||||||
|
clearTimeout(chordTimer);
|
||||||
|
chordTimer = window.setTimeout(() => (chord = ''), 300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chord === 'g' && routes[e.key]) {
|
||||||
|
location.href = routes[e.key]!;
|
||||||
|
chord = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.kbd-help {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
.kbd-help::backdrop { background: rgba(0, 0, 0, 0.4); }
|
||||||
|
.kbd-help h2 { font-size: var(--text-h3); color: var(--ink-1); margin-bottom: var(--space-3); }
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
td { padding: 4px 10px 4px 0; font-size: 14px; }
|
||||||
|
kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
.hint { margin-top: var(--space-3); font-size: 12px; color: var(--ink-3); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
// 开源项目墙(极客层 P0):构建时渲染,零运行时 JS
|
||||||
|
// 双源数据见 src/lib/projects.ts;语言色点用 linguist 常用色映射
|
||||||
|
import { loadProjects, type RepoCard } from '../../lib/projects';
|
||||||
|
|
||||||
|
const LANG_COLORS: Record<string, string> = {
|
||||||
|
Astro: '#ff5a03', TypeScript: '#3178c6', JavaScript: '#f1e05a',
|
||||||
|
Python: '#3572a5', Go: '#00add8', Rust: '#dea584', Shell: '#89e051',
|
||||||
|
HTML: '#e34c26', CSS: '#563d7c', Dockerfile: '#384d54',
|
||||||
|
};
|
||||||
|
|
||||||
|
let projects: RepoCard[] = [];
|
||||||
|
try {
|
||||||
|
projects = await loadProjects();
|
||||||
|
} catch {
|
||||||
|
projects = []; // 兜底已在 lib 内处理,此处再守一层:空墙不破构建
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="project-wall" aria-label="开源项目">
|
||||||
|
<h2 class="wall-title">开源 · Projects</h2>
|
||||||
|
<ul>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<li>
|
||||||
|
<a href={p.url} class="repo tap" rel="noopener noreferrer">
|
||||||
|
<span class="repo-name">
|
||||||
|
{p.source === 'gitea' ? '自托管' : 'GitHub'} / {p.name}
|
||||||
|
</span>
|
||||||
|
<span class="repo-desc">{p.description}</span>
|
||||||
|
<span class="repo-meta">
|
||||||
|
{p.language && (
|
||||||
|
<span class="lang">
|
||||||
|
<span class="dot" style={`background:${LANG_COLORS[p.language] ?? '#8b8f98'}`} />
|
||||||
|
{p.language}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span class="stars">★ {p.stars}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.project-wall { margin-top: var(--space-6); }
|
||||||
|
.wall-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--ink-3);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
ul { list-style: none; padding: 0; display: grid; gap: var(--space-2); }
|
||||||
|
.repo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: border-color var(--dur) var(--ease);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.repo:hover { border-color: var(--accent); }
|
||||||
|
.repo-name { font-family: var(--font-mono); font-size: 14px; color: var(--ink-1); }
|
||||||
|
.repo-desc { font-size: 13px; color: var(--ink-3); }
|
||||||
|
.repo-meta { display: flex; gap: var(--space-3); font-size: 12px; color: var(--ink-3); margin-top: 4px; }
|
||||||
|
.lang { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"source": "gitea",
|
||||||
|
"name": "blog",
|
||||||
|
"description": "本站源码:Astro SSG + 双写作流,内容即代码",
|
||||||
|
"language": "Astro",
|
||||||
|
"stars": 0,
|
||||||
|
"url": "https://git.wait4x.online/wait4x/blog",
|
||||||
|
"updatedAt": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -11,6 +11,9 @@ import '../styles/global.css';
|
|||||||
import Header from '../components/Header.astro';
|
import Header from '../components/Header.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import ThemeScript from '../components/ThemeScript.astro';
|
import ThemeScript from '../components/ThemeScript.astro';
|
||||||
|
import Keyboard from '../components/geek/Keyboard.astro';
|
||||||
|
import BuildInfo from '../components/geek/BuildInfo.astro';
|
||||||
|
import { ClientRouter } from 'astro:transitions';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -32,6 +35,7 @@ const { title, description = 'wait4x 的博客:阅读 · 技术 · 摄影', tr
|
|||||||
<meta name="theme-color" content="#191d23" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#191d23" media="(prefers-color-scheme: dark)" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<ThemeScript />
|
<ThemeScript />
|
||||||
|
<ClientRouter />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip-link" href="#main">跳到正文</a>
|
<a class="skip-link" href="#main">跳到正文</a>
|
||||||
@@ -39,6 +43,9 @@ const { title, description = 'wait4x 的博客:阅读 · 技术 · 摄影', tr
|
|||||||
<main id="main" class="container">
|
<main id="main" class="container">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer>
|
||||||
|
<BuildInfo slot="build-info" />
|
||||||
|
</Footer>
|
||||||
|
<Keyboard />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
|
||||||
|
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
|
||||||
|
export interface RepoCard {
|
||||||
|
source: 'gitea' | 'github';
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
language: string | null;
|
||||||
|
stars: number;
|
||||||
|
url: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawRepo {
|
||||||
|
full_name?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
language?: string | null;
|
||||||
|
stars_count?: number;
|
||||||
|
stargazers_count?: number;
|
||||||
|
html_url?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GITEA_API = 'https://git.wait4x.online/api/v1/users/wait4x/repos?limit=50';
|
||||||
|
export const GITHUB_API = 'https://api.github.com/users/wait4xx/repos?per_page=100&sort=updated';
|
||||||
|
|
||||||
|
/** 置顶清单:跨源手选,图钉顺序即展示顺序(name = 仓库短名) */
|
||||||
|
export const PINNED: string[] = ['blog'];
|
||||||
|
|
||||||
|
async function fetchJson(url: string): Promise<RawRepo[]> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
|
||||||
|
return (await res.json()) as RawRepo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCard(r: RawRepo, source: RepoCard['source']): RepoCard | null {
|
||||||
|
const name = r.name ?? r.full_name?.split('/')[1];
|
||||||
|
if (!name || !r.html_url) return null;
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
name,
|
||||||
|
description: r.description ?? '',
|
||||||
|
language: r.language ?? null,
|
||||||
|
stars: r.stars_count ?? r.stargazers_count ?? 0,
|
||||||
|
url: r.html_url,
|
||||||
|
updatedAt: r.updated_at ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。
|
||||||
|
* 任一源失败不抛:置顶项回落仓内缓存 JSON(构建产物绝不被网络单点卡死)。
|
||||||
|
*/
|
||||||
|
export async function loadProjects(): Promise<RepoCard[]> {
|
||||||
|
const [gitea, github] = await Promise.allSettled([fetchJson(GITEA_API), fetchJson(GITHUB_API)]);
|
||||||
|
const byName = new Map<string, RepoCard>();
|
||||||
|
|
||||||
|
for (const r of gitea.status === 'fulfilled' ? gitea.value : []) {
|
||||||
|
const c = toCard(r, 'gitea');
|
||||||
|
if (c) byName.set(c.name, c);
|
||||||
|
}
|
||||||
|
for (const r of github.status === 'fulfilled' ? github.value : []) {
|
||||||
|
const c = toCard(r, 'github');
|
||||||
|
if (!c) continue;
|
||||||
|
const existing = byName.get(c.name);
|
||||||
|
if (!existing) byName.set(c.name, c);
|
||||||
|
else if (c.stars > existing.stars) existing.stars = c.stars; // 社区真值覆盖镜像计数
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byName.size === 0) {
|
||||||
|
const fb = (await import('../data/projects-fallback.json')).default as RepoCard[];
|
||||||
|
return orderPinned(fb);
|
||||||
|
}
|
||||||
|
return orderPinned([...byName.values()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderPinned(list: RepoCard[]): RepoCard[] {
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const pa = PINNED.indexOf(a.name), pb = PINNED.indexOf(b.name);
|
||||||
|
if (pa !== -1 || pb !== -1) return (pa === -1 ? 99 : pa) - (pb === -1 ? 99 : pb);
|
||||||
|
return b.stars - a.stars;
|
||||||
|
}).slice(0, 6);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user