feat(cms): 首页四项可配 + 开源页项目可见性 + GitHub README 开篇
- 首页:subtitle(markdown 短文本)|latestPhotos 0-6(0 隐藏)|
perTrackLimit 4-8|sections{tracks,photos,heatmap,projects} 开关,
访问器 homeConfig() 夹取越界值,字段级回落默认(零配置=原形态)
- 开源页项目可见性:mode all|only|exclude + include[]/exclude[] +
sort pinned-first|stars|updated + perSource both|github|gitea;
projects.ts 加 LoadProjectsOptions + arrange()(来源→可见性→排序)
- 开源页开篇支持 GitHub README:intro_from off|github-readme,构建期
取 wait4xx/wait4xx README(token 复用宿主 0600 文件)→ 缓存
content/.github-readme.md 入仓 → renderMarkdown;失败逐级回落
(缓存 → yaml blurb)。实测取到 3117 字符,现状仍 off 待用户开启
- 修既有 bug:orderPinned 内硬 slice(0,6) 把开源页 limit=99 也截到 6 条
→ 移除,开源页恢复全量 16 条
- 文档:写作指南加首页/项目墙两张字段表(含 YAML 1.1 `off` 需加引号坑、
README 行为与外部徽章风险说明)
车道:op-gitea(3/3);收编复验:构建 48 页 + E2E 37/37 + 开源页 16 条 +
首页默认形态(2 轨/热力图/项目墙)无回归
验证: node v26.13.0 构建 48 页 exit 0;E2E 37/37 PASS;playwright 实测
Spec: none (首页与开源页后台扩展,车道级交付)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
8d221ab36e
commit
859cac19a2
11 files changed
+386
-25
No files matched your search
+19
-1
@@ -1,9 +1,14 @@
|
|||||||
# 主页身份语(后台「页面文案 → 主页设置」可改)
|
# 主页身份语(后台「页面文案 → 主页设置」可改)
|
||||||
# zh/en 双字段,页面按 locale 取;留空则回落到 src/lib/site-content.ts 的默认文案
|
# 全部字段留空 = 现有形态(字段级回落默认值,零配置零变化)
|
||||||
tagline:
|
tagline:
|
||||||
zh: 写作者 · 读者 · 快门按压者,拥抱开源。
|
zh: 写作者 · 读者 · 快门按压者,拥抱开源。
|
||||||
en: Writer · Reader · Shutter-presser — embracing open source.
|
en: Writer · Reader · Shutter-presser — embracing open source.
|
||||||
|
|
||||||
|
# 副标题(可选,markdown 短文本):渲染在身份语下方,留空不渲染
|
||||||
|
# subtitle:
|
||||||
|
# zh: '正在把**阅读、代码与影像**收进同一个工作台'
|
||||||
|
# en: 'Collecting **reading, code and images** into one workbench'
|
||||||
|
|
||||||
# 头像(可选):上传后自动写入 ./assets/avatar-xxx.jpg(走 Astro 图片管线)
|
# 头像(可选):上传后自动写入 ./assets/avatar-xxx.jpg(走 Astro 图片管线)
|
||||||
# 留空 = 回退内置渐变占位
|
# 留空 = 回退内置渐变占位
|
||||||
# avatar: ./assets/avatar.jpg
|
# avatar: ./assets/avatar.jpg
|
||||||
@@ -24,3 +29,16 @@ socials:
|
|||||||
- label: 邮件
|
- label: 邮件
|
||||||
url: mailto:admin@wait4x.online
|
url: mailto:admin@wait4x.online
|
||||||
icon: email
|
icon: email
|
||||||
|
|
||||||
|
# 最新影像张数(1-6,默认 4;填 0 = 隐藏该区块)
|
||||||
|
latestPhotos: 4
|
||||||
|
|
||||||
|
# 首页每轨文章条数(4-8,默认 6)
|
||||||
|
perTrackLimit: 6
|
||||||
|
|
||||||
|
# 区块开关(默认全开;关掉的区块不渲染)
|
||||||
|
sections:
|
||||||
|
tracks: true
|
||||||
|
photos: true
|
||||||
|
heatmap: true
|
||||||
|
projects: true
|
||||||
@@ -6,3 +6,17 @@ repo_label: git.wait4x.online/wait4x/blog
|
|||||||
blurb_after:
|
blurb_after:
|
||||||
zh: —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。
|
zh: —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。
|
||||||
en: — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.
|
en: — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.
|
||||||
|
|
||||||
|
# ---- 项目墙可见性与排序(后台「开源页」可改;留空/缺省 = 现状) ----
|
||||||
|
# mode: all=全部(默认) | only=仅 include 列出 | exclude=排除 exclude 列出
|
||||||
|
mode: all
|
||||||
|
# 白名单(mode=only 时生效)
|
||||||
|
include: []
|
||||||
|
# 黑名单(mode=exclude 时生效)
|
||||||
|
exclude: []
|
||||||
|
# 排序:pinned-first=置顶优先(默认) | stars=星数降序 | updated=最近更新
|
||||||
|
sort: pinned-first
|
||||||
|
# 来源过滤:both=双源合并(默认) | github | gitea
|
||||||
|
perSource: both
|
||||||
|
# 开篇文案来源:off=用本文件 blurb(默认) | github-readme=构建期取 wait4xx/wait4xx 的 README 渲染
|
||||||
|
intro_from: "off"
|
||||||
@@ -195,6 +195,33 @@ markdown 原生语法,无需任何配置:
|
|||||||
图标可选 `github / rss / email / bilibili / x`,**留空则显示纯文字按钮**。
|
图标可选 `github / rss / email / bilibili / x`,**留空则显示纯文字按钮**。
|
||||||
首页名片区、以及任何引用该组件的页面都吃同一份数据(单一事实源,改一次全站生效)。
|
首页名片区、以及任何引用该组件的页面都吃同一份数据(单一事实源,改一次全站生效)。
|
||||||
|
|
||||||
|
#### 首页可配项(home.yaml)
|
||||||
|
|
||||||
|
| 字段 | 取值 | 默认 | 作用 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `subtitle` | `{zh, en}`(markdown 短文本) | 空 | 身份语下方的副标题;留空不渲染 |
|
||||||
|
| `latestPhotos` | 0-6 | 4 | 「最新影像」张数;**0 = 隐藏该区块** |
|
||||||
|
| `perTrackLimit` | 4-8 | 6 | 首页阅读/技术两轨各显示条数 |
|
||||||
|
| `sections` | `{tracks, photos, heatmap, projects}` 布尔 | 全开 | 区块开关,关掉即不渲染 |
|
||||||
|
|
||||||
|
越界值会被夹取(如 perTrackLimit=20 按 8 处理),不报错;**全部留空 = 现有形态不变**。
|
||||||
|
|
||||||
|
#### 开源页项目墙(opensource.yaml)
|
||||||
|
|
||||||
|
| 字段 | 取值 | 默认 | 作用 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `mode` | `all` / `only` / `exclude` | all | 可见性模式 |
|
||||||
|
| `include` | 仓短名数组 | 空 | mode=only 时的白名单 |
|
||||||
|
| `exclude` | 仓短名数组 | 空 | mode=exclude 时的黑名单 |
|
||||||
|
| `sort` | `pinned-first` / `stars` / `updated` | pinned-first | 排序方式 |
|
||||||
|
| `perSource` | `both` / `github` / `gitea` | both | 来源过滤 |
|
||||||
|
| `intro_from` | `"off"` / `github-readme` | off | 开篇文案来源 |
|
||||||
|
|
||||||
|
> `intro_from` 建议**带引号写**:YAML 1.1 里裸 `off` 会被解析成布尔 false。
|
||||||
|
> 选 `github-readme` 时构建期取 `wait4xx/wait4xx` 的 README 渲染为开篇,
|
||||||
|
> 成功后缓存到 `content/.github-readme.md`(入仓);token 缺位/取失败/仓无 README
|
||||||
|
> 都会打印日志并回落 yaml 的 blurb。注意 README 里的相对链接与外部徽章会原样输出。
|
||||||
|
|
||||||
#### 首页文章列表
|
#### 首页文章列表
|
||||||
|
|
||||||
首页现在分两段:**阅读轨 6 条 + 技术轨 6 条**(各自带「全部文章」出口:阅读→书架、技术→归档)。
|
首页现在分两段:**阅读轨 6 条 + 技术轨 6 条**(各自带「全部文章」出口:阅读→书架、技术→归档)。
|
||||||
|
|||||||
@@ -123,6 +123,39 @@ collections:
|
|||||||
fields:
|
fields:
|
||||||
- { label: 中文, name: zh, widget: string, required: false }
|
- { label: 中文, name: zh, widget: string, required: false }
|
||||||
- { label: 英文, name: en, widget: string, required: false }
|
- { label: 英文, name: en, widget: string, required: false }
|
||||||
|
- label: 副标题(可选,markdown 短文本)
|
||||||
|
name: subtitle
|
||||||
|
widget: object
|
||||||
|
hint: 渲染在身份语下方;留空不渲染
|
||||||
|
fields:
|
||||||
|
- { label: 中文, name: zh, widget: string, required: false }
|
||||||
|
- { label: 英文, name: en, widget: string, required: false }
|
||||||
|
- label: 最新影像张数
|
||||||
|
name: latestPhotos
|
||||||
|
widget: number
|
||||||
|
required: false
|
||||||
|
default: 4
|
||||||
|
min: 0
|
||||||
|
max: 6
|
||||||
|
hint: '1-6;填 0 = 隐藏「最新影像」区块。留空默认 4'
|
||||||
|
- label: 首页每轨文章条数
|
||||||
|
name: perTrackLimit
|
||||||
|
widget: number
|
||||||
|
required: false
|
||||||
|
default: 6
|
||||||
|
min: 4
|
||||||
|
max: 8
|
||||||
|
hint: 阅读轨/技术轨各显示条数,留空默认 6
|
||||||
|
- label: 区块开关
|
||||||
|
name: sections
|
||||||
|
widget: object
|
||||||
|
required: false
|
||||||
|
hint: 关掉的区块不渲染(默认全开)
|
||||||
|
fields:
|
||||||
|
- { label: 文章列表, name: tracks, widget: boolean, required: false, default: true }
|
||||||
|
- { label: 最新影像, name: photos, widget: boolean, required: false, default: true }
|
||||||
|
- { label: 贡献热力图, name: heatmap, widget: boolean, required: false, default: true }
|
||||||
|
- { label: 项目墙, name: projects, widget: boolean, required: false, default: true }
|
||||||
- label: 头像(可选)
|
- label: 头像(可选)
|
||||||
name: avatar
|
name: avatar
|
||||||
widget: image
|
widget: image
|
||||||
@@ -181,3 +214,44 @@ collections:
|
|||||||
widget: list
|
widget: list
|
||||||
required: false
|
required: false
|
||||||
hint: '如 blog;留空则回落默认 blog。填写的名字须与仓库短名一致'
|
hint: '如 blog;留空则回落默认 blog。填写的名字须与仓库短名一致'
|
||||||
|
- label: 项目墙模式
|
||||||
|
name: mode
|
||||||
|
widget: select
|
||||||
|
required: false
|
||||||
|
default: all
|
||||||
|
options: [{ label: 全部, value: all }, { label: 仅白名单, value: only }, { label: 排除黑名单, value: exclude }]
|
||||||
|
hint: only/exclude 需配合下面的名单
|
||||||
|
- label: 白名单(仓库短名)
|
||||||
|
name: include
|
||||||
|
widget: list
|
||||||
|
required: false
|
||||||
|
hint: 'mode=仅白名单时生效,如 pi-board'
|
||||||
|
- label: 黑名单(仓库短名)
|
||||||
|
name: exclude
|
||||||
|
widget: list
|
||||||
|
required: false
|
||||||
|
hint: 'mode=排除黑名单时生效,如 blog'
|
||||||
|
- label: 排序
|
||||||
|
name: sort
|
||||||
|
widget: select
|
||||||
|
required: false
|
||||||
|
default: pinned-first
|
||||||
|
options:
|
||||||
|
- { label: 置顶优先,其余按星数, value: pinned-first }
|
||||||
|
- { label: 星数降序, value: stars }
|
||||||
|
- { label: 最近更新, value: updated }
|
||||||
|
- label: 来源过滤
|
||||||
|
name: perSource
|
||||||
|
widget: select
|
||||||
|
required: false
|
||||||
|
default: both
|
||||||
|
options: [{ label: 双源合并, value: both }, { label: 仅 GitHub, value: github }, { label: 仅自托管 gitea, value: gitea }]
|
||||||
|
- label: 开篇文案来源
|
||||||
|
name: intro_from
|
||||||
|
widget: select
|
||||||
|
required: false
|
||||||
|
default: 'off'
|
||||||
|
options:
|
||||||
|
- { label: 用本页叙述(默认), value: 'off' }
|
||||||
|
- { label: GitHub profile README, value: github-readme }
|
||||||
|
hint: 'github-readme = 构建期取 wait4xx/wait4xx 的 README 渲染为开篇;缓存入仓 content/.github-readme.md,取不到自动回落叙述'
|
||||||
@@ -12,8 +12,16 @@ interface Props {
|
|||||||
locale?: Locale;
|
locale?: Locale;
|
||||||
/** 置顶仓短名(顺序即展示),由 content/site/opensource.yaml 供给 */
|
/** 置顶仓短名(顺序即展示),由 content/site/opensource.yaml 供给 */
|
||||||
pinned?: string[];
|
pinned?: string[];
|
||||||
|
/** 可见性/排序/来源选项(同上,开源页消费) */
|
||||||
|
options?: {
|
||||||
|
mode?: 'all' | 'only' | 'exclude';
|
||||||
|
include?: string[];
|
||||||
|
exclude?: string[];
|
||||||
|
sort?: 'pinned-first' | 'stars' | 'updated';
|
||||||
|
perSource?: 'both' | 'github' | 'gitea';
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const { limit = 6, locale = 'zh', pinned } = Astro.props;
|
const { limit = 6, locale = 'zh', pinned, options } = Astro.props;
|
||||||
const title = t('open_source_projects', locale);
|
const title = t('open_source_projects', locale);
|
||||||
const LANG_COLORS: Record<string, string> = {
|
const LANG_COLORS: Record<string, string> = {
|
||||||
Astro: '#ff5a03', TypeScript: '#3178c6', JavaScript: '#f1e05a',
|
Astro: '#ff5a03', TypeScript: '#3178c6', JavaScript: '#f1e05a',
|
||||||
@@ -23,7 +31,7 @@ const LANG_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
let projects: RepoCard[] = [];
|
let projects: RepoCard[] = [];
|
||||||
try {
|
try {
|
||||||
projects = (await loadProjects(pinned)).slice(0, limit);
|
projects = (await loadProjects({ pinned, ...options })).slice(0, limit);
|
||||||
} catch {
|
} catch {
|
||||||
projects = []; // 兜底已在 lib 内处理,此处再守一层:空墙不破构建
|
projects = []; // 兜底已在 lib 内处理,此处再守一层:空墙不破构建
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,21 @@ export const collections = {
|
|||||||
avatar: image().optional(),
|
avatar: image().optional(),
|
||||||
/** 站点图标:public 下的路径(.svg/.ico 直出,不经图片管线);缺省回退 /favicon.svg */
|
/** 站点图标:public 下的路径(.svg/.ico 直出,不经图片管线);缺省回退 /favicon.svg */
|
||||||
favicon: z.string().optional(),
|
favicon: z.string().optional(),
|
||||||
|
/** 副标题(markdown 短文本,渲染在身份语下);空 = 不渲染 */
|
||||||
|
subtitle: bilingual.optional(),
|
||||||
|
/** 最新影像张数:0 隐藏区块,1-6 有效(越界由访问器夹取) */
|
||||||
|
latestPhotos: z.number().int().optional(),
|
||||||
|
/** 首页每轨条数:4-8(越界由访问器夹取,缺省 6) */
|
||||||
|
perTrackLimit: z.number().int().optional(),
|
||||||
|
/** 区块开关:缺省全开 */
|
||||||
|
sections: z
|
||||||
|
.object({
|
||||||
|
tracks: z.boolean().optional(),
|
||||||
|
photos: z.boolean().optional(),
|
||||||
|
heatmap: z.boolean().optional(),
|
||||||
|
projects: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
/** 社交链接,顺序即展示顺序;icon 为内置图标集键(github/rss/email/...) */
|
/** 社交链接,顺序即展示顺序;icon 为内置图标集键(github/rss/email/...) */
|
||||||
socials: z
|
socials: z
|
||||||
.array(
|
.array(
|
||||||
@@ -110,6 +125,16 @@ export const collections = {
|
|||||||
repo_label: z.string().optional(),
|
repo_label: z.string().optional(),
|
||||||
/** 置顶仓短名,顺序即展示顺序(原 projects.ts 硬编码名单迁于此) */
|
/** 置顶仓短名,顺序即展示顺序(原 projects.ts 硬编码名单迁于此) */
|
||||||
pinned: z.array(z.string()).optional(),
|
pinned: z.array(z.string()).optional(),
|
||||||
|
/** 项目墙可见性:all=全部 / only=仅 include / exclude=排除 exclude */
|
||||||
|
mode: z.enum(['all', 'only', 'exclude']).optional(),
|
||||||
|
include: z.array(z.string()).optional(),
|
||||||
|
exclude: z.array(z.string()).optional(),
|
||||||
|
/** 排序:pinned-first(默认)/ stars / updated */
|
||||||
|
sort: z.enum(['pinned-first', 'stars', 'updated']).optional(),
|
||||||
|
/** 来源过滤:both(默认)/ github / gitea */
|
||||||
|
perSource: z.enum(['both', 'github', 'gitea']).optional(),
|
||||||
|
/** 开篇文案来源:off(默认,用本文件 blurb)/ github-readme(构建期取 profile 仓 README) */
|
||||||
|
intro_from: z.enum(['off', 'github-readme']).optional(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
// slug 即文件名(glob loader 默认按文件名生成 id)
|
// slug 即文件名(glob loader 默认按文件名生成 id)
|
||||||
|
|||||||
+39
-8
@@ -54,11 +54,25 @@ function toCard(r: RawRepo, source: RepoCard['source']): RepoCard | null {
|
|||||||
* 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。
|
* 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。
|
||||||
* 任一源失败不抛:置顶项回落仓内缓存 JSON(构建产物绝不被网络单点卡死)。
|
* 任一源失败不抛:置顶项回落仓内缓存 JSON(构建产物绝不被网络单点卡死)。
|
||||||
*/
|
*/
|
||||||
|
export interface LoadProjectsOptions {
|
||||||
|
/** 置顶仓短名(顺序即顺序),缺省 PINNED_DEFAULT */
|
||||||
|
pinned?: string[];
|
||||||
|
/** 可见性:all=全部 / only=仅 include / exclude=排除 exclude */
|
||||||
|
mode?: 'all' | 'only' | 'exclude';
|
||||||
|
include?: string[];
|
||||||
|
exclude?: string[];
|
||||||
|
/** 排序:pinned-first(默认)/ stars / updated */
|
||||||
|
sort?: 'pinned-first' | 'stars' | 'updated';
|
||||||
|
/** 来源过滤:both(默认)/ github / gitea */
|
||||||
|
perSource?: 'both' | 'github' | 'gitea';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拉取双源并排序(置顶名单由 content/site/opensource.yaml 供给,缺省用 PINNED_DEFAULT)
|
* 拉取双源并按选项过滤/排序(选项来自 content/site/opensource.yaml,缺省 = 现状行为)
|
||||||
* @param pinned 置顶仓短名,顺序即展示顺序
|
* @returns 未截断的完整列表:展示上限由调用方 slice(首页 6 / 开源页 99)
|
||||||
*/
|
*/
|
||||||
export async function loadProjects(pinned: string[] = PINNED_DEFAULT): Promise<RepoCard[]> {
|
export async function loadProjects(options: LoadProjectsOptions = {}): Promise<RepoCard[]> {
|
||||||
|
const { pinned = PINNED_DEFAULT, mode = 'all', include = [], exclude = [], sort = 'pinned-first', perSource = 'both' } = options;
|
||||||
const [gitea, github] = await Promise.allSettled([fetchJson(GITEA_API), fetchJson(GITHUB_API)]);
|
const [gitea, github] = await Promise.allSettled([fetchJson(GITEA_API), fetchJson(GITHUB_API)]);
|
||||||
const byName = new Map<string, RepoCard>();
|
const byName = new Map<string, RepoCard>();
|
||||||
|
|
||||||
@@ -76,15 +90,32 @@ export async function loadProjects(pinned: string[] = PINNED_DEFAULT): Promise<R
|
|||||||
|
|
||||||
if (byName.size === 0) {
|
if (byName.size === 0) {
|
||||||
const fb = (await import('../data/projects-fallback.json')).default as RepoCard[];
|
const fb = (await import('../data/projects-fallback.json')).default as RepoCard[];
|
||||||
return orderPinned(fb, pinned);
|
return arrange(fb, pinned, mode, include, exclude, sort, perSource);
|
||||||
}
|
}
|
||||||
return orderPinned([...byName.values()], pinned);
|
return arrange([...byName.values()], pinned, mode, include, exclude, sort, perSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderPinned(list: RepoCard[], pinned: string[]): RepoCard[] {
|
/** 来源过滤 + 可见性过滤 + 排序(不截断:上限交给调用方) */
|
||||||
return [...list].sort((a, b) => {
|
function arrange(
|
||||||
|
list: RepoCard[],
|
||||||
|
pinned: string[],
|
||||||
|
mode: 'all' | 'only' | 'exclude',
|
||||||
|
include: string[],
|
||||||
|
exclude: string[],
|
||||||
|
sort: 'pinned-first' | 'stars' | 'updated',
|
||||||
|
perSource: 'both' | 'github' | 'gitea',
|
||||||
|
): RepoCard[] {
|
||||||
|
let out = perSource === 'both' ? list : list.filter((r) => r.source === perSource);
|
||||||
|
if (mode === 'only') out = out.filter((r) => include.includes(r.name));
|
||||||
|
else if (mode === 'exclude') out = out.filter((r) => !exclude.includes(r.name));
|
||||||
|
|
||||||
|
if (sort === 'stars') return [...out].sort((a, b) => b.stars - a.stars);
|
||||||
|
if (sort === 'updated') {
|
||||||
|
return [...out].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
}
|
||||||
|
return [...out].sort((a, b) => {
|
||||||
const pa = pinned.indexOf(a.name), pb = pinned.indexOf(b.name);
|
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);
|
if (pa !== -1 || pb !== -1) return (pa === -1 ? 99 : pa) - (pb === -1 ? 99 : pb);
|
||||||
return b.stars - a.stars;
|
return b.stars - a.stars;
|
||||||
}).slice(0, 6);
|
});
|
||||||
}
|
}
|
||||||
@@ -131,3 +131,127 @@ export async function aboutIntroMarkdown(locale: Locale): Promise<string> {
|
|||||||
const entry = await getEntry('siteAbout', 'about');
|
const entry = await getEntry('siteAbout', 'about');
|
||||||
return pickBi(entry?.data.intro, SITE_DEFAULTS.aboutIntro, locale);
|
return pickBi(entry?.data.intro, SITE_DEFAULTS.aboutIntro, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 车道「首页+开源页后台扩展」:首页四项可配 + 项目墙可见性 + README 开篇 ----
|
||||||
|
|
||||||
|
export interface HomeSections {
|
||||||
|
tracks: boolean;
|
||||||
|
photos: boolean;
|
||||||
|
heatmap: boolean;
|
||||||
|
projects: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeConfig {
|
||||||
|
/** 副标题 markdown 原文;空串 = 不渲染 */
|
||||||
|
subtitle: string;
|
||||||
|
/** 最新影像张数:0 = 隐藏区块 */
|
||||||
|
latestPhotos: number;
|
||||||
|
/** 每轨文章条数 */
|
||||||
|
perTrackLimit: number;
|
||||||
|
sections: HomeSections;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampInt = (value: number | undefined, min: number, max: number, fallback: number): number => {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 首页可配项(字段级回落:零配置 = 现有形态;越界值夹取而非报错) */
|
||||||
|
export async function homeConfig(locale: Locale): Promise<HomeConfig> {
|
||||||
|
const entry = await getEntry('siteHome', 'home');
|
||||||
|
const d = entry?.data;
|
||||||
|
return {
|
||||||
|
subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale),
|
||||||
|
latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4),
|
||||||
|
perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6),
|
||||||
|
sections: {
|
||||||
|
tracks: d?.sections?.tracks ?? true,
|
||||||
|
photos: d?.sections?.photos ?? true,
|
||||||
|
heatmap: d?.sections?.heatmap ?? true,
|
||||||
|
projects: d?.sections?.projects ?? true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectMode = 'all' | 'only' | 'exclude';
|
||||||
|
export type ProjectSort = 'pinned-first' | 'stars' | 'updated';
|
||||||
|
export type ProjectSource = 'both' | 'github' | 'gitea';
|
||||||
|
|
||||||
|
export interface OpenSourceOptions {
|
||||||
|
mode: ProjectMode;
|
||||||
|
include: string[];
|
||||||
|
exclude: string[];
|
||||||
|
sort: ProjectSort;
|
||||||
|
perSource: ProjectSource;
|
||||||
|
introFrom: 'off' | 'github-readme';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开源页项目墙选项(缺省 = 现状:all / pinned-first / 双源) */
|
||||||
|
export async function openSourceOptions(): Promise<OpenSourceOptions> {
|
||||||
|
const entry = await getEntry('siteOpenSource', 'opensource');
|
||||||
|
const d = entry?.data;
|
||||||
|
return {
|
||||||
|
mode: d?.mode ?? 'all',
|
||||||
|
include: (d?.include ?? []).filter((n) => n.trim()),
|
||||||
|
exclude: (d?.exclude ?? []).filter((n) => n.trim()),
|
||||||
|
sort: d?.sort ?? 'pinned-first',
|
||||||
|
perSource: d?.perSource ?? 'both',
|
||||||
|
introFrom: d?.intro_from ?? 'off',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GitHub profile README 缓存:取不到网就吃缓存,都无则调用方回落 yaml blurb */
|
||||||
|
const README_CACHE = 'content/.github-readme.md';
|
||||||
|
const PROFILE_REPO = 'wait4xx/wait4xx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建期取 profile 仓 README(Accept: raw),成功后回写 content/.github-readme.md(入仓缓存)
|
||||||
|
* @returns README markdown;网络/权限/仓无 README 时回落缓存,仍无 → null
|
||||||
|
*/
|
||||||
|
export async function githubReadmeMarkdown(): Promise<string | null> {
|
||||||
|
const { readFileSync, writeFileSync, existsSync } = await import('node:fs');
|
||||||
|
const { join } = await import('node:path');
|
||||||
|
const cachePath = join(process.cwd(), README_CACHE);
|
||||||
|
const readCache = (): string | null => {
|
||||||
|
try {
|
||||||
|
return existsSync(cachePath) ? readFileSync(cachePath, 'utf8') : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let token: string | null = null;
|
||||||
|
try {
|
||||||
|
token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim() || null;
|
||||||
|
} catch {
|
||||||
|
token = null;
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
console.warn('[opensource] github-readme:token 缺位,用缓存/回落 yaml blurb');
|
||||||
|
return readCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.github.com/repos/${PROFILE_REPO}/readme`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github.raw', 'User-Agent': 'wait4x-blog-build' },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[opensource] github-readme:${PROFILE_REPO} 取 README 失败 HTTP ${res.status},用缓存/回落`);
|
||||||
|
return readCache();
|
||||||
|
}
|
||||||
|
const md = (await res.text()).trim();
|
||||||
|
if (!md) {
|
||||||
|
console.warn('[opensource] github-readme:README 为空,用缓存/回落');
|
||||||
|
return readCache();
|
||||||
|
}
|
||||||
|
const cached = readCache();
|
||||||
|
if (cached !== md) {
|
||||||
|
writeFileSync(cachePath, `${md}\n`, 'utf8');
|
||||||
|
console.log(`[opensource] github-readme:已刷新缓存 ${README_CACHE}(${md.length} 字符)`);
|
||||||
|
}
|
||||||
|
return md;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[opensource] github-readme:请求异常(${(err as Error).message}),用缓存/回落`);
|
||||||
|
return readCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,12 @@ import PostList from '../../components/PostList.astro';
|
|||||||
import ProjectWall from '../../components/geek/ProjectWall.astro';
|
import ProjectWall from '../../components/geek/ProjectWall.astro';
|
||||||
import Heatmap from '../../components/geek/Heatmap.astro';
|
import Heatmap from '../../components/geek/Heatmap.astro';
|
||||||
import { t } from '../../i18n/ui';
|
import { t } from '../../i18n/ui';
|
||||||
import { homeTagline } from '../../lib/site-content';
|
import { homeConfig, homeTagline, renderMarkdown } from '../../lib/site-content';
|
||||||
import SocialLinks from '../../components/SocialLinks.astro';
|
import SocialLinks from '../../components/SocialLinks.astro';
|
||||||
|
|
||||||
|
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落
|
||||||
|
const cfg = await homeConfig('en');
|
||||||
|
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
|
||||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
|
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
|
||||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
@@ -18,7 +21,7 @@ const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.t
|
|||||||
const enTitle = Object.fromEntries(
|
const enTitle = Object.fromEntries(
|
||||||
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
|
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
|
||||||
);
|
);
|
||||||
const photos = (await getCollection('photos')).slice(0, 4);
|
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
|
||||||
// 身份语与 zh 版同源(content/site/home.yaml 的 en 字段),缺值回落默认
|
// 身份语与 zh 版同源(content/site/home.yaml 的 en 字段),缺值回落默认
|
||||||
const tagline = await homeTagline('en');
|
const tagline = await homeTagline('en');
|
||||||
---
|
---
|
||||||
@@ -26,11 +29,13 @@ const tagline = await homeTagline('en');
|
|||||||
<BaseLayout title="wait4x" description="wait4x's blog: reading · code · photography">
|
<BaseLayout title="wait4x" description="wait4x's blog: reading · code · photography">
|
||||||
<IdCard>
|
<IdCard>
|
||||||
<p class="tagline">{tagline}</p>
|
<p class="tagline">{tagline}</p>
|
||||||
|
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
|
||||||
<SocialLinks />
|
<SocialLinks />
|
||||||
</IdCard>
|
</IdCard>
|
||||||
|
|
||||||
<PostList {posts} locale="en" titleOverrides={enTitle} perTrack={6} />
|
{cfg.sections.tracks && <PostList {posts} locale="en" titleOverrides={enTitle} perTrack={cfg.perTrackLimit} />}
|
||||||
|
|
||||||
|
{cfg.sections.photos && cfg.latestPhotos > 0 && (
|
||||||
<section class="latest" aria-label={t('latest_photos', 'en')}>
|
<section class="latest" aria-label={t('latest_photos', 'en')}>
|
||||||
<div class="latest-head">
|
<div class="latest-head">
|
||||||
<span class="lab">{t('latest_photos', 'en')}</span>
|
<span class="lab">{t('latest_photos', 'en')}</span>
|
||||||
@@ -49,8 +54,9 @@ const tagline = await homeTagline('en');
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Heatmap
|
{cfg.sections.heatmap && <Heatmap
|
||||||
labels={{
|
labels={{
|
||||||
title: t('heatmap_title', 'en'),
|
title: t('heatmap_title', 'en'),
|
||||||
summary: t('heatmap_summary', 'en'),
|
summary: t('heatmap_summary', 'en'),
|
||||||
@@ -58,11 +64,19 @@ const tagline = await homeTagline('en');
|
|||||||
more: t('heatmap_more', 'en'),
|
more: t('heatmap_more', 'en'),
|
||||||
note: t('heatmap_note', 'en'),
|
note: t('heatmap_note', 'en'),
|
||||||
}}
|
}}
|
||||||
/>
|
/>}
|
||||||
<ProjectWall locale="en" />
|
{cfg.sections.projects && <ProjectWall locale="en" />}
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.subtitle {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
margin-top: 2px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.subtitle :global(p) { margin: 0; }
|
||||||
|
.subtitle :global(a) { color: var(--accent); }
|
||||||
.tagline {
|
.tagline {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
|
|||||||
@@ -3,21 +3,33 @@ import ProjectWall from '../../components/geek/ProjectWall.astro';
|
|||||||
import Heatmap from '../../components/geek/Heatmap.astro';
|
import Heatmap from '../../components/geek/Heatmap.astro';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { t } from '../../i18n/ui';
|
import { t } from '../../i18n/ui';
|
||||||
import { openSourceCopy } from '../../lib/site-content';
|
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, renderMarkdown } from '../../lib/site-content';
|
||||||
|
|
||||||
const copy = await openSourceCopy('en');
|
const copy = await openSourceCopy('en');
|
||||||
|
const options = await openSourceOptions();
|
||||||
|
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
|
||||||
|
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
|
||||||
---
|
---
|
||||||
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
|
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
|
||||||
<h1 class="page-title">Open Source</h1>
|
<h1 class="page-title">Open Source</h1>
|
||||||
|
{readmeHtml ? (
|
||||||
|
<div class="blurb readme-intro" set:html={readmeHtml} />
|
||||||
|
) : (
|
||||||
<p class="blurb">{copy.blurb}<a class="link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}</p>
|
<p class="blurb">{copy.blurb}<a class="link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}</p>
|
||||||
|
)}
|
||||||
<Heatmap labels={{
|
<Heatmap labels={{
|
||||||
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
|
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
|
||||||
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
|
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
|
||||||
}} />
|
}} />
|
||||||
<ProjectWall limit={99} locale="en" pinned={copy.pinned} />
|
<ProjectWall limit={99} locale="en" pinned={copy.pinned} options={options} />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page-title { font-size: var(--text-h1); color: var(--ink-1); }
|
.page-title { font-size: var(--text-h1); color: var(--ink-1); }
|
||||||
|
.readme-intro :global(p) { margin-block: 0 var(--space-2); }
|
||||||
|
.readme-intro :global(ul) { margin-block: var(--space-1) var(--space-2); padding-left: 1.2em; list-style: disc; }
|
||||||
|
.readme-intro :global(h2), .readme-intro :global(h3) { font-size: 16px; margin: var(--space-3) 0 var(--space-1); }
|
||||||
|
.readme-intro :global(img) { max-width: 100%; height: auto; border-radius: 8px; }
|
||||||
|
.readme-intro :global(a) { color: var(--accent); }
|
||||||
.blurb { margin-top: var(--space-3); margin-bottom: var(--space-5); font-size: 15px; color: var(--ink-2); line-height: 1.7; max-width: 640px; }
|
.blurb { margin-top: var(--space-3); margin-bottom: var(--space-5); font-size: 15px; color: var(--ink-2); line-height: 1.7; max-width: 640px; }
|
||||||
</style>
|
</style>
|
||||||
+20
-6
@@ -8,14 +8,17 @@ import ProjectWall from '../components/geek/ProjectWall.astro';
|
|||||||
import Heatmap from '../components/geek/Heatmap.astro';
|
import Heatmap from '../components/geek/Heatmap.astro';
|
||||||
import { fetchHeartbeat, agoZh } from '../lib/heartbeat';
|
import { fetchHeartbeat, agoZh } from '../lib/heartbeat';
|
||||||
import { t } from '../i18n/ui';
|
import { t } from '../i18n/ui';
|
||||||
import { homeTagline } from '../lib/site-content';
|
import { homeConfig, homeTagline, renderMarkdown } from '../lib/site-content';
|
||||||
import SocialLinks from '../components/SocialLinks.astro';
|
import SocialLinks from '../components/SocialLinks.astro';
|
||||||
|
|
||||||
|
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落
|
||||||
|
const cfg = await homeConfig('zh');
|
||||||
|
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
|
||||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
|
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
|
||||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切
|
// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切
|
||||||
const photos = (await getCollection('photos')).slice(0, 4);
|
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
|
||||||
const heartbeat = await fetchHeartbeat();
|
const heartbeat = await fetchHeartbeat();
|
||||||
// 身份语改由 content/site/home.yaml 供给(后台可改),缺值回落默认文案
|
// 身份语改由 content/site/home.yaml 供给(后台可改),缺值回落默认文案
|
||||||
const tagline = await homeTagline('zh');
|
const tagline = await homeTagline('zh');
|
||||||
@@ -24,11 +27,13 @@ const tagline = await homeTagline('zh');
|
|||||||
<BaseLayout title="wait4x">
|
<BaseLayout title="wait4x">
|
||||||
<IdCard>
|
<IdCard>
|
||||||
<p class="tagline">{tagline}</p>
|
<p class="tagline">{tagline}</p>
|
||||||
|
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
|
||||||
<SocialLinks />
|
<SocialLinks />
|
||||||
</IdCard>
|
</IdCard>
|
||||||
|
|
||||||
<PostList {posts} perTrack={6} />
|
{cfg.sections.tracks && <PostList {posts} perTrack={cfg.perTrackLimit} />}
|
||||||
|
|
||||||
|
{cfg.sections.photos && cfg.latestPhotos > 0 && (
|
||||||
<section class="latest" aria-label="最新影像">
|
<section class="latest" aria-label="最新影像">
|
||||||
<div class="latest-head">
|
<div class="latest-head">
|
||||||
<span class="lab">最新影像</span>
|
<span class="lab">最新影像</span>
|
||||||
@@ -50,8 +55,9 @@ const tagline = await homeTagline('zh');
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Heatmap
|
{cfg.sections.heatmap && <Heatmap
|
||||||
labels={{
|
labels={{
|
||||||
title: t('heatmap_title', 'zh'),
|
title: t('heatmap_title', 'zh'),
|
||||||
summary: t('heatmap_summary', 'zh'),
|
summary: t('heatmap_summary', 'zh'),
|
||||||
@@ -59,8 +65,8 @@ const tagline = await homeTagline('zh');
|
|||||||
more: t('heatmap_more', 'zh'),
|
more: t('heatmap_more', 'zh'),
|
||||||
note: t('heatmap_note', 'zh'),
|
note: t('heatmap_note', 'zh'),
|
||||||
}}
|
}}
|
||||||
/>
|
/>}
|
||||||
<ProjectWall />
|
{cfg.sections.projects && <ProjectWall />}
|
||||||
{heartbeat && (
|
{heartbeat && (
|
||||||
<p class="heartbeat" aria-label="最近心跳">
|
<p class="heartbeat" aria-label="最近心跳">
|
||||||
<span class="beat-dot" aria-hidden="true"></span>
|
<span class="beat-dot" aria-hidden="true"></span>
|
||||||
@@ -70,6 +76,14 @@ const tagline = await homeTagline('zh');
|
|||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.subtitle {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
margin-top: 2px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.subtitle :global(p) { margin: 0; }
|
||||||
|
.subtitle :global(a) { color: var(--accent); }
|
||||||
.tagline {
|
.tagline {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
|
|||||||
Reference in new issue
Block a user