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:
1 parent
5be09b6f32
commit
264148f0a6
15 files changed
+346
-12
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
# 关于页可变段落(后台「页面文案 → 关于页」可改)
|
||||||
|
# intro = 身份陈述段;留空则回落默认文案,构建不炸
|
||||||
|
intro:
|
||||||
|
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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 主页身份语(后台「页面文案 → 主页设置」可改)
|
||||||
|
# zh/en 双字段,页面按 locale 取;留空则回落到 src/lib/site-content.ts 的默认文案
|
||||||
|
tagline:
|
||||||
|
zh: 写作者 · 读者 · 快门按压者,拥抱开源。
|
||||||
|
en: Writer · Reader · Shutter-presser — embracing open source.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 开源页叙述(后台「页面文案 → 开源页」可改)
|
||||||
|
# blurb = 链接前文案,repo_* 拼成仓链接,blurb_after = 链接后文案;任一留空回落默认
|
||||||
|
blurb:
|
||||||
|
zh: 本站本身即开源:
|
||||||
|
en: 'This site is itself open source: '
|
||||||
|
repo_url: https://git.wait4x.online/wait4x/blog
|
||||||
|
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.
|
||||||
|
# 置顶项目(跨源手选,顺序即展示顺序;name = 仓库短名)。原 projects.ts 硬编码名单迁于此
|
||||||
|
pinned:
|
||||||
|
- blog
|
||||||
@@ -65,6 +65,24 @@ public/media/my-post/clip.mp4 ← 文件放这里
|
|||||||
|
|
||||||
见 `content/posts/media-sample.md`(draft 文章,仅构建验证用,不上线)。
|
见 `content/posts/media-sample.md`(draft 文章,仅构建验证用,不上线)。
|
||||||
|
|
||||||
|
## 链接卡片(OG 预览卡)
|
||||||
|
|
||||||
|
**一行只放一个链接、且它独立成段**时,构建期会抓取该页的 OG 信息渲染成卡片(标题 / 描述 / 站点图标 / 可选缩略图):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
https://astro.build/
|
||||||
|
|
||||||
|
[也可以是 markdown 链接形态](https://astro.build/)
|
||||||
|
```
|
||||||
|
|
||||||
|
规则与边界:
|
||||||
|
|
||||||
|
- **内联链接不变**:夹在句子里的链接保持普通样式(`这是一句带 [链接](https://example.com) 的话` → 不会变卡),所以不会把整篇变成卡片墙
|
||||||
|
- **抓取失败一律回落普通链接**:404、无 OG、超时(4s)、站点拒爬都不影响构建
|
||||||
|
- **每篇最多 10 张卡**,超出的回落普通链接(防止文章变卡片流)
|
||||||
|
- **缓存**:结果存 `content/.link-cache.json`(入仓,保证构建确定性);条目 30 天后自动重抓。想强制刷新某条——删掉该 URL 的条目即可
|
||||||
|
- 缩略图取 `og:image`,无图不占位;图标取站点而非第三方 favicon 服务
|
||||||
|
|
||||||
## 发布提醒
|
## 发布提醒
|
||||||
|
|
||||||
- push main → webhook 自动构建发布(约 10 秒)
|
- push main → webhook 自动构建发布(约 10 秒)
|
||||||
@@ -90,3 +108,45 @@ mood: "#8a6f4a"
|
|||||||
cover: ./assets/books/mian-sha.jpg
|
cover: ./assets/books/mian-sha.jpg
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 后台上传(Sveltia 后台,推荐路径)
|
||||||
|
|
||||||
|
后台地址 `/admin/`(gitea 账号登录)。三类媒体都有后台入口,不必手写路径:
|
||||||
|
|
||||||
|
### 图片
|
||||||
|
|
||||||
|
文章编辑器里插入图片 → 自动上传到 `content/posts/assets/` 并写入 `./assets/...`
|
||||||
|
相对路径(走 Astro 图片管线,自动优化)。**不要手改成 `/assets/...` 绝对路径**。
|
||||||
|
|
||||||
|
### 视频(frontmatter 字段,正文后自动渲染)
|
||||||
|
|
||||||
|
1. 文章编辑页「附视频」字段上传(自动落 `public/media/`,路径写 `/media/<name>.mp4`)
|
||||||
|
2. 正文读完后自动出现 `<video controls preload="metadata" playsinline>`,全宽圆角黑底
|
||||||
|
3. 「视频封面」填 `/media/xxx.jpg` 可自定首帧;留空浏览器自取
|
||||||
|
4. 无视频字段 → 该区块不渲染(零占位)
|
||||||
|
|
||||||
|
体积纪律:mp4/webm ≤50MB 走 `public/media/`(构建原样拷贝,不转码);
|
||||||
|
更大的请按上文「体积纪律」放仓外 store(该场景 build.sh 同步段待首次出现时补)。
|
||||||
|
|
||||||
|
### 链接
|
||||||
|
|
||||||
|
markdown 原生语法,无需任何配置:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[文字](https://example.com)
|
||||||
|
```
|
||||||
|
|
||||||
|
想让链接显示成 OG 预览卡(带标题/描述/图标),把它**单独放一行**即可——规则见上文「链接卡片(OG 预览卡)」。
|
||||||
|
|
||||||
|
### 页面文案(主页/关于/开源)
|
||||||
|
|
||||||
|
后台「页面文案」下三个入口:
|
||||||
|
|
||||||
|
| 入口 | 文件 | 可改内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| 主页设置 | `content/site/home.yaml` | 身份语 tagline(zh/en) |
|
||||||
|
| 关于页 | `content/site/about.yaml` | 身份陈述段 intro(zh/en) |
|
||||||
|
| 开源页 | `content/site/opensource.yaml` | 叙述 blurb/blurb_after、仓链接、置顶项目 pinned |
|
||||||
|
|
||||||
|
- 中文/英文各一个字段,页面按语言取;**留空则该字段回落默认文案**(构建不会炸)
|
||||||
|
- `pinned` 填仓库短名(如 `blog`、`pi-board`),**顺序即展示顺序**;留空回落 `blog`
|
||||||
@@ -39,6 +39,18 @@ collections:
|
|||||||
- { label: 原书作者, name: bookAuthor, widget: string, required: false }
|
- { label: 原书作者, name: bookAuthor, widget: string, required: false }
|
||||||
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false }
|
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false }
|
||||||
- { label: 书封图, name: cover, widget: image, required: false, hint: '上传后自动生成,无需手填路径;仅阅读轨展示' }
|
- { label: 书封图, name: cover, widget: image, required: false, hint: '上传后自动生成,无需手填路径;仅阅读轨展示' }
|
||||||
|
- label: 附视频(可选)
|
||||||
|
name: video
|
||||||
|
widget: file
|
||||||
|
required: false
|
||||||
|
media_folder: public/media
|
||||||
|
public_folder: /media
|
||||||
|
hint: '展示在正文之后;mp4/webm ≤50MB(走 public/media,构建时原样拷贝,不做转码)'
|
||||||
|
- label: 视频封面(可选)
|
||||||
|
name: poster
|
||||||
|
widget: string
|
||||||
|
required: false
|
||||||
|
hint: '填 /media/xxx.jpg(先上传到 public/media 取路径);缺省浏览器自取首帧'
|
||||||
- { label: 隐藏(不进列表/sitemap), name: hiddenTag, widget: boolean, required: false, hint: '对应 tags 含 hidden;保存后请人工核对' }
|
- { label: 隐藏(不进列表/sitemap), name: hiddenTag, widget: boolean, required: false, hint: '对应 tags 含 hidden;保存后请人工核对' }
|
||||||
- { label: 草稿, name: draft, widget: boolean, required: false, default: false }
|
- { label: 草稿, name: draft, widget: boolean, required: false, default: false }
|
||||||
- { label: 正文, name: body, widget: markdown }
|
- { label: 正文, name: body, widget: markdown }
|
||||||
@@ -86,3 +98,53 @@ collections:
|
|||||||
- { label: 光圈值, name: fNumber, widget: number, required: false }
|
- { label: 光圈值, name: fNumber, widget: number, required: false }
|
||||||
- { label: 曝光秒, name: exposureTime, widget: number, required: false }
|
- { label: 曝光秒, name: exposureTime, widget: number, required: false }
|
||||||
- { label: ISO, name: iso, widget: number, required: false }
|
- { label: ISO, name: iso, widget: number, required: false }
|
||||||
|
|
||||||
|
# 页面文案单例(cms 车道 A):三个文件型入口,保存即写 content/site/*.yaml,
|
||||||
|
# 字段与 Astro siteHome/siteAbout/siteOpenSource 的 schema 一一对应(zh/en 双字段,留空回落默认)
|
||||||
|
- name: site_pages
|
||||||
|
label: 页面文案
|
||||||
|
files:
|
||||||
|
- name: home
|
||||||
|
label: 主页设置
|
||||||
|
file: content/site/home.yaml
|
||||||
|
fields:
|
||||||
|
- label: 身份语
|
||||||
|
name: tagline
|
||||||
|
widget: object
|
||||||
|
fields:
|
||||||
|
- { label: 中文, name: zh, widget: string, required: false }
|
||||||
|
- { label: 英文, name: en, widget: string, required: false }
|
||||||
|
- name: about
|
||||||
|
label: 关于页
|
||||||
|
file: content/site/about.yaml
|
||||||
|
fields:
|
||||||
|
- label: 身份陈述段
|
||||||
|
name: intro
|
||||||
|
widget: object
|
||||||
|
hint: 渲染在「关于」标题下方、名片之上;留空用默认文案
|
||||||
|
fields:
|
||||||
|
- { label: 中文, name: zh, widget: text, required: false }
|
||||||
|
- { label: 英文, name: en, widget: text, required: false }
|
||||||
|
- name: opensource
|
||||||
|
label: 开源页
|
||||||
|
file: content/site/opensource.yaml
|
||||||
|
fields:
|
||||||
|
- label: 叙述(链接前)
|
||||||
|
name: blurb
|
||||||
|
widget: object
|
||||||
|
fields:
|
||||||
|
- { label: 中文, name: zh, widget: string, required: false }
|
||||||
|
- { label: 英文, name: en, widget: string, required: false }
|
||||||
|
- { label: 仓库链接地址, name: repo_url, widget: string, required: false }
|
||||||
|
- { label: 仓库链接文字, name: repo_label, widget: string, required: false }
|
||||||
|
- label: 叙述(链接后)
|
||||||
|
name: blurb_after
|
||||||
|
widget: object
|
||||||
|
fields:
|
||||||
|
- { label: 中文, name: zh, widget: text, required: false }
|
||||||
|
- { label: 英文, name: en, widget: text, required: false }
|
||||||
|
- label: 置顶项目(仓库短名,顺序即展示顺序)
|
||||||
|
name: pinned
|
||||||
|
widget: list
|
||||||
|
required: false
|
||||||
|
hint: '如 blog;留空则回落默认 blog。填写的名字须与仓库短名一致'
|
||||||
@@ -10,8 +10,10 @@ interface Props {
|
|||||||
/** 展示上限:首页精选 6;/opensource 全量传大数 */
|
/** 展示上限:首页精选 6;/opensource 全量传大数 */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
locale?: Locale;
|
locale?: Locale;
|
||||||
|
/** 置顶仓短名(顺序即展示),由 content/site/opensource.yaml 供给 */
|
||||||
|
pinned?: string[];
|
||||||
}
|
}
|
||||||
const { limit = 6, locale = 'zh' } = Astro.props;
|
const { limit = 6, locale = 'zh', pinned } = 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',
|
||||||
@@ -21,7 +23,7 @@ const LANG_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
let projects: RepoCard[] = [];
|
let projects: RepoCard[] = [];
|
||||||
try {
|
try {
|
||||||
projects = (await loadProjects()).slice(0, limit);
|
projects = (await loadProjects(pinned)).slice(0, limit);
|
||||||
} catch {
|
} catch {
|
||||||
projects = []; // 兜底已在 lib 内处理,此处再守一层:空墙不破构建
|
projects = []; // 兜底已在 lib 内处理,此处再守一层:空墙不破构建
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
|||||||
import IdCard from '../../components/IdCard.astro';
|
import IdCard from '../../components/IdCard.astro';
|
||||||
import { t } from '../../i18n/ui';
|
import { t } from '../../i18n/ui';
|
||||||
import type { Locale } from '../../lib/locale';
|
import type { Locale } from '../../lib/locale';
|
||||||
|
import { aboutIntro } from '../../lib/site-content';
|
||||||
|
|
||||||
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||||
interface Props {
|
interface Props {
|
||||||
locale?: Locale;
|
locale?: Locale;
|
||||||
}
|
}
|
||||||
const { locale = 'zh' } = Astro.props;
|
const { locale = 'zh' } = Astro.props;
|
||||||
|
// 身份陈述段改由 content/site/about.yaml 供给(后台可改),缺值回落默认
|
||||||
|
const intro = await aboutIntro(locale);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`${t('about_title', locale)} · wait4x`}>
|
<BaseLayout title={`${t('about_title', locale)} · wait4x`}>
|
||||||
<h1 class="page-title">{t('about_title', locale)}</h1>
|
<h1 class="page-title">{t('about_title', locale)}</h1>
|
||||||
|
|
||||||
|
<p class="intro">{intro}</p>
|
||||||
|
|
||||||
<IdCard />
|
<IdCard />
|
||||||
|
|
||||||
<section class="tracks" aria-label={t('about_tracks_title', locale)}>
|
<section class="tracks" aria-label={t('about_tracks_title', locale)}>
|
||||||
@@ -72,6 +77,13 @@ const { locale = 'zh' } = Astro.props;
|
|||||||
margin-top: var(--space-5);
|
margin-top: var(--space-5);
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
.intro {
|
||||||
|
font-size: 15.5px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--ink-2);
|
||||||
|
max-width: var(--measure);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
.sec-title {
|
.sec-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export const POST_TRACKS = {
|
|||||||
export type PostCategory = keyof typeof POST_TRACKS;
|
export type PostCategory = keyof typeof POST_TRACKS;
|
||||||
export type PostTrack = (typeof POST_TRACKS)[PostCategory];
|
export type PostTrack = (typeof POST_TRACKS)[PostCategory];
|
||||||
|
|
||||||
|
/** 双语文案字段:两语言各自可选,缺一回落一(回退逻辑在 src/lib/site-content.ts) */
|
||||||
|
const bilingual = z.object({ zh: z.string().optional(), en: z.string().optional() });
|
||||||
|
|
||||||
export function trackOf(category: PostCategory): PostTrack {
|
export function trackOf(category: PostCategory): PostTrack {
|
||||||
return POST_TRACKS[category];
|
return POST_TRACKS[category];
|
||||||
}
|
}
|
||||||
@@ -30,6 +33,10 @@ const postsSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
/** 原书作者(书架卡署名) */
|
/** 原书作者(书架卡署名) */
|
||||||
bookAuthor: z.string().optional(),
|
bookAuthor: z.string().optional(),
|
||||||
|
/** 文章附视频(单文件):存 public/media,frontmatter 写 /media/<name>.mp4 */
|
||||||
|
video: z.string().optional(),
|
||||||
|
/** 视频封面(可选):同 public/media 下的图片 URL */
|
||||||
|
poster: z.string().optional(),
|
||||||
draft: z.boolean().optional(),
|
draft: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +70,30 @@ const _exifShapeOk: _PhotoExifOut extends PhotoExif
|
|||||||
: never = true;
|
: never = true;
|
||||||
|
|
||||||
export const collections = {
|
export const collections = {
|
||||||
|
// 站点文案单例(后台「页面文案」可改):一文件一集合
|
||||||
|
// 用 glob(pattern 精确到单文件) 而非 file():Astro 的 file() 把 YAML 顶层对象当“条目映射”
|
||||||
|
// (每个顶层键各自成一条 entry,见 astro/dist/content/loaders/file.js),根级字段会被拆碎;
|
||||||
|
// glob 每文件一条 entry(id=文件名)、data=文件根对象,正是单例语义且 Sveltia 可直接编根字段
|
||||||
|
// 双语文案用 {zh,en} 对象字段(各自可选):任一语言空/缺字段 → src/lib/site-content.ts 回落默认
|
||||||
|
siteHome: defineCollection({
|
||||||
|
loader: glob({ pattern: 'home.yaml', base: './content/site' }),
|
||||||
|
schema: z.object({ tagline: bilingual.optional() }),
|
||||||
|
}),
|
||||||
|
siteAbout: defineCollection({
|
||||||
|
loader: glob({ pattern: 'about.yaml', base: './content/site' }),
|
||||||
|
schema: z.object({ intro: bilingual.optional() }),
|
||||||
|
}),
|
||||||
|
siteOpenSource: defineCollection({
|
||||||
|
loader: glob({ pattern: 'opensource.yaml', base: './content/site' }),
|
||||||
|
schema: z.object({
|
||||||
|
blurb: bilingual.optional(),
|
||||||
|
blurb_after: bilingual.optional(),
|
||||||
|
repo_url: z.string().optional(),
|
||||||
|
repo_label: z.string().optional(),
|
||||||
|
/** 置顶仓短名,顺序即展示顺序(原 projects.ts 硬编码名单迁于此) */
|
||||||
|
pinned: z.array(z.string()).optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
// slug 即文件名(glob loader 默认按文件名生成 id)
|
// slug 即文件名(glob loader 默认按文件名生成 id)
|
||||||
posts: defineCollection({
|
posts: defineCollection({
|
||||||
loader: glob({ pattern: '**/*.md', base: './content/posts' }),
|
loader: glob({ pattern: '**/*.md', base: './content/posts' }),
|
||||||
|
|||||||
+11
-7
@@ -24,8 +24,8 @@ interface RawRepo {
|
|||||||
export const GITEA_API = 'https://git.wait4x.online/api/v1/users/wait4x/repos?limit=50';
|
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';
|
export const GITHUB_API = 'https://api.github.com/users/wait4xx/repos?per_page=100&sort=updated';
|
||||||
|
|
||||||
/** 置顶清单:跨源手选,图钉顺序即展示顺序(name = 仓库短名) */
|
/** 默认置顶(仅在调用方未传 pinned 时生效):现由 content/site/opensource.yaml 的 pinned 供数 */
|
||||||
export const PINNED: string[] = ['blog'];
|
export const PINNED_DEFAULT: string[] = ['blog'];
|
||||||
|
|
||||||
async function fetchJson(url: string): Promise<RawRepo[]> {
|
async function fetchJson(url: string): Promise<RawRepo[]> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -54,7 +54,11 @@ function toCard(r: RawRepo, source: RepoCard['source']): RepoCard | null {
|
|||||||
* 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。
|
* 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。
|
||||||
* 任一源失败不抛:置顶项回落仓内缓存 JSON(构建产物绝不被网络单点卡死)。
|
* 任一源失败不抛:置顶项回落仓内缓存 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 [gitea, github] = await Promise.allSettled([fetchJson(GITEA_API), fetchJson(GITHUB_API)]);
|
||||||
const byName = new Map<string, RepoCard>();
|
const byName = new Map<string, RepoCard>();
|
||||||
|
|
||||||
@@ -72,14 +76,14 @@ export async function loadProjects(): Promise<RepoCard[]> {
|
|||||||
|
|
||||||
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);
|
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) => {
|
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);
|
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);
|
}).slice(0, 6);
|
||||||
|
|||||||
@@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { getCollection, render } from 'astro:content';
|
import { getCollection, render } from 'astro:content';
|
||||||
|
import LinkCardStyles from '../../components/geek/LinkCardStyles.astro';
|
||||||
import PhotoZoom from '../../components/geek/PhotoZoom.astro';
|
import PhotoZoom from '../../components/geek/PhotoZoom.astro';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import ArtalkComments from '../../components/ArtalkComments.astro';
|
import ArtalkComments from '../../components/ArtalkComments.astro';
|
||||||
@@ -63,6 +64,14 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|||||||
</header>
|
</header>
|
||||||
<div class="prose-reading">
|
<div class="prose-reading">
|
||||||
<Content />
|
<Content />
|
||||||
|
{entry.data.video && (
|
||||||
|
<figure class="post-video">
|
||||||
|
<video controls preload="metadata" playsinline poster={entry.data.poster}>
|
||||||
|
<source src={entry.data.video} />
|
||||||
|
你的浏览器不支持内嵌视频,<a href={entry.data.video}>点此下载观看</a>。
|
||||||
|
</video>
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
) : (
|
) : (
|
||||||
@@ -83,6 +92,14 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|||||||
<div class="tech-grid">
|
<div class="tech-grid">
|
||||||
<div class="prose-tech">
|
<div class="prose-tech">
|
||||||
<Content />
|
<Content />
|
||||||
|
{entry.data.video && (
|
||||||
|
<figure class="post-video">
|
||||||
|
<video controls preload="metadata" playsinline poster={entry.data.poster}>
|
||||||
|
<source src={entry.data.video} />
|
||||||
|
你的浏览器不支持内嵌视频,<a href={entry.data.video}>点此下载观看</a>。
|
||||||
|
</video>
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{toc.length > 0 && (
|
{toc.length > 0 && (
|
||||||
<aside class="toc" aria-label="目录">
|
<aside class="toc" aria-label="目录">
|
||||||
@@ -117,6 +134,7 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
|
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
|
||||||
|
<LinkCardStyles />
|
||||||
<PhotoZoom />
|
<PhotoZoom />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
@@ -335,6 +353,17 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|||||||
/* ---- T6 书评纸卡(08 裁定 §6-2):通栏 hairline 升圆角纸卡 ----
|
/* ---- T6 书评纸卡(08 裁定 §6-2):通栏 hairline 升圆角纸卡 ----
|
||||||
--paper 三态已入 tokens(亮米白/暗暖棕夜灯),零新变量;7000px 长文
|
--paper 三态已入 tokens(亮米白/暗暖棕夜灯),零新变量;7000px 长文
|
||||||
左右边框恒为 1px hairline 防两根竖线感(§6-2 落地注意) */
|
左右边框恒为 1px hairline 防两根竖线感(§6-2 落地注意) */
|
||||||
|
/* 文章附视频(cms 车道 B):等宽自适应、serve 源不在站内 */
|
||||||
|
.post-video {
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
}
|
||||||
|
.post-video video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #000;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
.prose-reading {
|
.prose-reading {
|
||||||
border: 1px solid var(--paper-line);
|
border: 1px solid var(--paper-line);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// /en/archives/{slug}:英文版文章页——postsEn 优先;缺译回退 zh 正文 + 醒目标注
|
// /en/archives/{slug}:英文版文章页——postsEn 优先;缺译回退 zh 正文 + 醒目标注
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { getCollection, render } from 'astro:content';
|
import { getCollection, render } from 'astro:content';
|
||||||
|
import LinkCardStyles from '../../../components/geek/LinkCardStyles.astro';
|
||||||
import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
|
import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
|
||||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||||
import ArtalkComments from '../../../components/ArtalkComments.astro';
|
import ArtalkComments from '../../../components/ArtalkComments.astro';
|
||||||
@@ -61,6 +62,14 @@ const minutes = Math.max(1, Math.ceil(words / 220));
|
|||||||
<AiTranslatedBadge />
|
<AiTranslatedBadge />
|
||||||
<div class={isReading ? 'prose-reading' : 'prose-tech'}>
|
<div class={isReading ? 'prose-reading' : 'prose-tech'}>
|
||||||
<Content />
|
<Content />
|
||||||
|
{entry.data.video && (
|
||||||
|
<figure class="post-video">
|
||||||
|
<video controls preload="metadata" playsinline poster={entry.data.poster}>
|
||||||
|
<source src={entry.data.video} />
|
||||||
|
Your browser cannot play embedded video — <a href={entry.data.video}>download it instead</a>.
|
||||||
|
</video>
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<nav class="pn">
|
<nav class="pn">
|
||||||
{prev && <a class="pn-item" href={`/en/archives/${prev.id}/`}>← {t('prev_post', 'en')} · {prev.data.title}</a>}
|
{prev && <a class="pn-item" href={`/en/archives/${prev.id}/`}>← {t('prev_post', 'en')} · {prev.data.title}</a>}
|
||||||
@@ -68,6 +77,7 @@ const minutes = Math.max(1, Math.ceil(words / 220));
|
|||||||
</nav>
|
</nav>
|
||||||
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
|
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
|
||||||
</article>
|
</article>
|
||||||
|
<LinkCardStyles />
|
||||||
<PhotoZoom />
|
<PhotoZoom />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
@@ -116,6 +126,17 @@ const minutes = Math.max(1, Math.ceil(words / 220));
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- T6 reading paper card (§6-2) ---- */
|
/* ---- T6 reading paper card (§6-2) ---- */
|
||||||
|
/* 文章附视频:与 zh 版同构(cms 车道 B) */
|
||||||
|
.post-video {
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
}
|
||||||
|
.post-video video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #000;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
.prose-reading {
|
.prose-reading {
|
||||||
border: 1px solid var(--paper-line);
|
border: 1px solid var(--paper-line);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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';
|
||||||
|
|
||||||
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(),
|
||||||
@@ -17,10 +18,13 @@ 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, 4);
|
||||||
|
// 身份语与 zh 版同源(content/site/home.yaml 的 en 字段),缺值回落默认
|
||||||
|
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>
|
||||||
<div class="socials">
|
<div class="socials">
|
||||||
<a
|
<a
|
||||||
class="tap social"
|
class="tap social"
|
||||||
@@ -103,6 +107,12 @@ const photos = (await getCollection('photos')).slice(0, 4);
|
|||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.tagline {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
margin-top: 4px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
.socials {
|
.socials {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ 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';
|
||||||
|
|
||||||
|
const copy = await openSourceCopy('en');
|
||||||
---
|
---
|
||||||
<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>
|
||||||
<p class="blurb">This site is itself open source: <a class="link" href="https://git.wait4x.online/wait4x/blog">git.wait4x.online/wait4x/blog</a> — 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.</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" />
|
<ProjectWall limit={99} locale="en" pinned={copy.pinned} />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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';
|
||||||
|
|
||||||
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(),
|
||||||
@@ -15,11 +16,13 @@ const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.t
|
|||||||
// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切
|
// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切
|
||||||
const photos = (await getCollection('photos')).slice(0, 4);
|
const photos = (await getCollection('photos')).slice(0, 4);
|
||||||
const heartbeat = await fetchHeartbeat();
|
const heartbeat = await fetchHeartbeat();
|
||||||
|
// 身份语改由 content/site/home.yaml 供给(后台可改),缺值回落默认文案
|
||||||
|
const tagline = await homeTagline('zh');
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="wait4x">
|
<BaseLayout title="wait4x">
|
||||||
<IdCard>
|
<IdCard>
|
||||||
<p class="tagline">写作者 · 读者 · 快门按压者,拥抱开源。</p>
|
<p class="tagline">{tagline}</p>
|
||||||
<div class="socials">
|
<div class="socials">
|
||||||
<a
|
<a
|
||||||
class="tap social"
|
class="tap social"
|
||||||
|
|||||||
Reference in new issue
Block a user