feat(cms): 内容管理闭环——站点单例三件套+文章视频字段

- A 站点文案单例:content/site/{home,about,opensource}.yaml +
  src/lib/site-content.ts 访问层(字段级回落默认,默认值=原文案逐字
  一致,零回归);接线主页 tagline(en 首页新增该块)、关于页 intro、
  开源页叙述三段 + PINNED 参数化(projects.ts: loadProjects(pinned)
  /orderPinned,PINNED_DEFAULT 仅缺省兜底)
- loader 语义修正(返工如实入档):Astro 7 的 file() 把 YAML 顶层对象
  按 Object.entries 拆成多条目(id=key、data=value),单例字段必炸
  schema;改 glob 精确到单文件 = 真单例语义,data 即文件根对象,
  Sveltia 可编辑根字段零嵌套(比包 main: 层的方案后台体验更好)
- B 文章媒体:posts +video/+poster;Sveltia file 字段
  (media_folder=public/media、public_folder=/media、hint ≤50MB);
  正文后渲染 video controls preload=metadata playsinline,无则不渲染;
  图片链路核对本已一致(./assets 走 Astro 管线);链接 markdown 原生
- docs/写作指南.md 补「后台上传」节(三类媒体 + 三单例操作 + 体积纪律)

Sveltia 集合:posts / moments / photos / site_pages(三单例)

车道:op-gitea(实测 tagline 改 yaml 产物联动、置空回落默认、pinned
改名排序变、视频临时件渲染全对、构建绿 + lint 45 页 0/0/0);收编
复验:构建 44 页 + E2E 37/37 + 三 yaml 扁平键就位

验证: pnpm astro build 44 页 exit 0;E2E 37/37 PASS;content-lint
      0/0/0;播放器/单例/置顶 playwright 实测全过

Spec: none (内容管理闭环,车道级交付)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 12:42:10 +08:00
1 parent 5be09b6f32
commit 264148f0a6
15 files changed
+346 -12

No files matched your search

+11 -7
View File
@@ -24,8 +24,8 @@ interface RawRepo {
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'];
/** 默认置顶(仅在调用方未传 pinned 时生效):现由 content/site/opensource.yaml 的 pinned 供数 */
export const PINNED_DEFAULT: string[] = ['blog'];
async function fetchJson(url: string): Promise<RawRepo[]> {
const res = await fetch(url, {
@@ -54,7 +54,11 @@ function toCard(r: RawRepo, source: RepoCard['source']): RepoCard | null {
* 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。
* 任一源失败不抛:置顶项回落仓内缓存 JSON(构建产物绝不被网络单点卡死)。
*/
export async function loadProjects(): Promise<RepoCard[]> {
/**
* 拉取双源并排序(置顶名单由 content/site/opensource.yaml 供给,缺省用 PINNED_DEFAULT)
* @param pinned 置顶仓短名,顺序即展示顺序
*/
export async function loadProjects(pinned: string[] = PINNED_DEFAULT): Promise<RepoCard[]> {
const [gitea, github] = await Promise.allSettled([fetchJson(GITEA_API), fetchJson(GITHUB_API)]);
const byName = new Map<string, RepoCard>();
@@ -72,14 +76,14 @@ export async function loadProjects(): Promise<RepoCard[]> {
if (byName.size === 0) {
const fb = (await import('../data/projects-fallback.json')).default as RepoCard[];
return orderPinned(fb);
return orderPinned(fb, pinned);
}
return orderPinned([...byName.values()]);
return orderPinned([...byName.values()], pinned);
}
function orderPinned(list: RepoCard[]): RepoCard[] {
function orderPinned(list: RepoCard[], pinned: string[]): RepoCard[] {
return [...list].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);
return b.stars - a.stars;
}).slice(0, 6);
+74
View File
@@ -0,0 +1,74 @@
// 站点文案单例访问层(cms 车道 A):后台改 content/site/*.yaml → 页面读此处
// 兜底纪律:文件缺 → 用默认文案;字段缺/空串 → 用默认文案(构建绝不因文案炸)
// 注意:整文件被删会先在 file() loader 层报错(误删级),此处守的是字段级缺失/空值
import { getEntry } from 'astro:content';
import type { Locale } from './locale';
export interface Bi {
zh: string;
en: string;
}
/** 与仓库现状逐字一致的默认文案:yaml 全空时产物不变(零回归可视验证锚点) */
export const SITE_DEFAULTS = {
homeTagline: {
zh: '写作者 · 读者 · 快门按压者,拥抱开源。',
en: 'Writer · Reader · Shutter-presser — embracing open source.',
},
aboutIntro: {
zh: '写作者 · 读者 · 快门按压者,拥抱开源。这里是我三条轨道的公开工作台:读过的书留下评注,写过的代码进项目墙,按下的快门收入图库。',
en: 'Writer · Reader · Shutter-presser, embracing open source. This is the public workbench of my three tracks — books that left notes, code that reaches the project wall, and shutters I pressed.',
},
opensource: {
blurb: { zh: '本站本身即开源:', en: 'This site is itself open source: ' },
blurbAfter: {
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.',
},
repoUrl: 'https://git.wait4x.online/wait4x/blog',
repoLabel: 'git.wait4x.online/wait4x/blog',
pinned: ['blog'] as string[],
},
} as const;
/** 字段级回落:yaml 值非空才采信(空白串视为空) */
const pick = (value: string | undefined, fallback: string): string =>
value && value.trim() ? value : fallback;
const pickBi = (value: { zh?: string; en?: string } | undefined, fallback: Bi, locale: Locale): string =>
pick(value?.[locale], fallback[locale]);
/** 主页 tagline(zh/en) */
export async function homeTagline(locale: Locale): Promise<string> {
const entry = await getEntry('siteHome', 'home');
return pickBi(entry?.data.tagline, SITE_DEFAULTS.homeTagline, locale);
}
/** 关于页身份陈述段(zh/en) */
export async function aboutIntro(locale: Locale): Promise<string> {
const entry = await getEntry('siteAbout', 'about');
return pickBi(entry?.data.intro, SITE_DEFAULTS.aboutIntro, locale);
}
export interface OpenSourceCopy {
blurb: string;
blurbAfter: string;
repoUrl: string;
repoLabel: string;
/** 置顶仓短名(顺序即顺序);yaml 空数组/缺省 → 默认 ['blog'] */
pinned: string[];
}
/** 开源页叙述 + 置顶名单 */
export async function openSourceCopy(locale: Locale): Promise<OpenSourceCopy> {
const entry = await getEntry('siteOpenSource', 'opensource');
const d = SITE_DEFAULTS.opensource;
const pinned = entry?.data.pinned?.filter((n) => n.trim()) ?? [];
return {
blurb: pickBi(entry?.data.blurb, d.blurb, locale),
blurbAfter: pickBi(entry?.data.blurb_after, d.blurbAfter, locale),
repoUrl: pick(entry?.data.repo_url, d.repoUrl),
repoLabel: pick(entry?.data.repo_label, d.repoLabel),
pinned: pinned.length ? pinned : [...d.pinned],
};
}