diff --git a/content/site/home.yaml b/content/site/home.yaml index 0473380..0c390a4 100644 --- a/content/site/home.yaml +++ b/content/site/home.yaml @@ -1,9 +1,14 @@ # 主页身份语(后台「页面文案 → 主页设置」可改) -# zh/en 双字段,页面按 locale 取;留空则回落到 src/lib/site-content.ts 的默认文案 +# 全部字段留空 = 现有形态(字段级回落默认值,零配置零变化) tagline: zh: 写作者 · 读者 · 快门按压者,拥抱开源。 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 图片管线) # 留空 = 回退内置渐变占位 # avatar: ./assets/avatar.jpg @@ -24,3 +29,16 @@ socials: - label: 邮件 url: mailto:admin@wait4x.online icon: email + +# 最新影像张数(1-6,默认 4;填 0 = 隐藏该区块) +latestPhotos: 4 + +# 首页每轨文章条数(4-8,默认 6) +perTrackLimit: 6 + +# 区块开关(默认全开;关掉的区块不渲染) +sections: + tracks: true + photos: true + heatmap: true + projects: true diff --git a/content/site/opensource.yaml b/content/site/opensource.yaml index 99f4660..b0dff4e 100644 --- a/content/site/opensource.yaml +++ b/content/site/opensource.yaml @@ -6,3 +6,17 @@ repo_label: git.wait4x.online/wait4x/blog blurb_after: 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. + +# ---- 项目墙可见性与排序(后台「开源页」可改;留空/缺省 = 现状) ---- +# 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" diff --git a/docs/写作指南.md b/docs/写作指南.md index aaa00b4..db4504c 100644 --- a/docs/写作指南.md +++ b/docs/写作指南.md @@ -195,6 +195,33 @@ markdown 原生语法,无需任何配置: 图标可选 `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 条**(各自带「全部文章」出口:阅读→书架、技术→归档)。 diff --git a/public/admin/config.yml b/public/admin/config.yml index 24ea94d..36e1ec3 100644 --- a/public/admin/config.yml +++ b/public/admin/config.yml @@ -123,6 +123,39 @@ collections: fields: - { label: 中文, name: zh, 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: 头像(可选) name: avatar widget: image @@ -181,3 +214,44 @@ collections: widget: list required: false 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,取不到自动回落叙述' diff --git a/src/components/geek/ProjectWall.astro b/src/components/geek/ProjectWall.astro index 6b50d0c..68ff66f 100644 --- a/src/components/geek/ProjectWall.astro +++ b/src/components/geek/ProjectWall.astro @@ -12,8 +12,16 @@ interface Props { locale?: Locale; /** 置顶仓短名(顺序即展示),由 content/site/opensource.yaml 供给 */ 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 LANG_COLORS: Record = { Astro: '#ff5a03', TypeScript: '#3178c6', JavaScript: '#f1e05a', @@ -23,7 +31,7 @@ const LANG_COLORS: Record = { let projects: RepoCard[] = []; try { - projects = (await loadProjects(pinned)).slice(0, limit); + projects = (await loadProjects({ pinned, ...options })).slice(0, limit); } catch { projects = []; // 兜底已在 lib 内处理,此处再守一层:空墙不破构建 } diff --git a/src/content.config.ts b/src/content.config.ts index e38d5a0..eb4c203 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -84,6 +84,21 @@ export const collections = { avatar: image().optional(), /** 站点图标:public 下的路径(.svg/.ico 直出,不经图片管线);缺省回退 /favicon.svg */ 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/...) */ socials: z .array( @@ -110,6 +125,16 @@ export const collections = { repo_label: z.string().optional(), /** 置顶仓短名,顺序即展示顺序(原 projects.ts 硬编码名单迁于此) */ 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) diff --git a/src/lib/projects.ts b/src/lib/projects.ts index e9f1c78..32b9229 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -54,11 +54,25 @@ function toCard(r: RawRepo, source: RepoCard['source']): RepoCard | null { * 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。 * 任一源失败不抛:置顶项回落仓内缓存 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) - * @param pinned 置顶仓短名,顺序即展示顺序 + * 拉取双源并按选项过滤/排序(选项来自 content/site/opensource.yaml,缺省 = 现状行为) + * @returns 未截断的完整列表:展示上限由调用方 slice(首页 6 / 开源页 99) */ -export async function loadProjects(pinned: string[] = PINNED_DEFAULT): Promise { +export async function loadProjects(options: LoadProjectsOptions = {}): Promise { + 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 byName = new Map(); @@ -76,15 +90,32 @@ export async function loadProjects(pinned: string[] = PINNED_DEFAULT): Promise { +/** 来源过滤 + 可见性过滤 + 排序(不截断:上限交给调用方) */ +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); if (pa !== -1 || pb !== -1) return (pa === -1 ? 99 : pa) - (pb === -1 ? 99 : pb); return b.stars - a.stars; - }).slice(0, 6); + }); } diff --git a/src/lib/site-content.ts b/src/lib/site-content.ts index c536443..cf84b80 100644 --- a/src/lib/site-content.ts +++ b/src/lib/site-content.ts @@ -131,3 +131,127 @@ export async function aboutIntroMarkdown(locale: Locale): Promise { const entry = await getEntry('siteAbout', 'about'); 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 { + 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 { + 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 { + 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(); + } +} diff --git a/src/pages/en/index.astro b/src/pages/en/index.astro index 02b859a..26fcbd3 100644 --- a/src/pages/en/index.astro +++ b/src/pages/en/index.astro @@ -8,9 +8,12 @@ import PostList from '../../components/PostList.astro'; import ProjectWall from '../../components/geek/ProjectWall.astro'; import Heatmap from '../../components/geek/Heatmap.astro'; 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'; +// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落 +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( (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( (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 字段),缺值回落默认 const tagline = await homeTagline('en'); --- @@ -26,11 +29,13 @@ const tagline = await homeTagline('en');

{tagline}

+ {subtitleHtml &&
} - + {cfg.sections.tracks && } + {cfg.sections.photos && cfg.latestPhotos > 0 && (
{t('latest_photos', 'en')} @@ -49,8 +54,9 @@ const tagline = await homeTagline('en'); ))}
+ )} - - + />} + {cfg.sections.projects && } diff --git a/src/pages/index.astro b/src/pages/index.astro index d5ed296..28bd0bf 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -8,14 +8,17 @@ import ProjectWall from '../components/geek/ProjectWall.astro'; import Heatmap from '../components/geek/Heatmap.astro'; import { fetchHeartbeat, agoZh } from '../lib/heartbeat'; 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'; +// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落 +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( (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(), ); // 最新影像取集合前 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(); // 身份语改由 content/site/home.yaml 供给(后台可改),缺值回落默认文案 const tagline = await homeTagline('zh'); @@ -24,11 +27,13 @@ const tagline = await homeTagline('zh');

{tagline}

+ {subtitleHtml &&
} - + {cfg.sections.tracks && } + {cfg.sections.photos && cfg.latestPhotos > 0 && (
最新影像 @@ -50,8 +55,9 @@ const tagline = await homeTagline('zh'); ))}
+ )} - - + />} + {cfg.sections.projects && } {heartbeat && (

@@ -70,6 +76,14 @@ const tagline = await homeTagline('zh');