feat: 初始化 Astro 学习博客骨架

- Astro+TS 严格模式(禁止 any、显式返回类型)
- ESLint Flat Config + Prettier 格式化
- 内容集合(posts)与示例文章
- OSS 图床可配置 Img 组件
- 抽离 posts 数据逻辑(更易类型检查)
- getStaticPaths 修正(rest 路由传字符串)
- README/AGENTS 中文文档与约定
- Gitea CI:typecheck + lint
- 忽略 ref/(仅作临时参考)
This commit is contained in:
2025-11-27 17:08:07 +08:00
commit 6cc96a7126
24 changed files with 5381 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Check
on:
push:
branches:
- main
- master
- develop
- feature/**
pull_request:
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
+13
View File
@@ -0,0 +1,13 @@
# Node.js
node_modules/
# Astro
.astro/
# Build output
dist/
public/images/
ref/
# Miscellaneous
.DS_Store
+1
View File
@@ -0,0 +1 @@
use-node-version=24.11.1
+17
View File
@@ -0,0 +1,17 @@
{
"printWidth": 100,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"plugins": ["prettier-plugin-astro"],
"overrides": [
{
"files": "*.mdx",
"options": { "proseWrap": "always" }
},
{
"files": "*.md",
"options": { "proseWrap": "always" }
}
]
}
+75
View File
@@ -0,0 +1,75 @@
# AGENTS 指南(本仓库)
本仓库用于搭建一套基于 Astro 的内部学习博客站点。目标:TypeScript 严格(拒绝 any)、代码与文档统一格式化、内容格式可扩展(不仅限于 Markdown),图片优先走外部 OSS,不建议把图片直接提交进仓库。
## 工作流原则
- 语言与文档:优先中文说明;代码与注释保持简洁明确。
- TypeScript 严格:启用 `strict``noImplicitAny`ESLint 禁止 `any`
- 格式化:统一使用 Prettier(含 Astro、Markdown);提交前请执行 `pnpm format` 或启用编辑器保存自动格式化。
- 类型与检查:强制显式函数返回类型,禁止隐式 any;建议在 CI 中执行 `pnpm typecheck``pnpm lint`
- 目录结构:见下文“项目结构”。
- 内容来源:默认支持 `md`/`mdx`,并预留可扩展的内容加载层(如将来支持 Typst 等)。
- 图片与静态资源:默认不入库。通过环境变量配置外部 OSS 图床地址,在组件中统一引用。
- `ref/` 目录仅作临时参考:不纳入版本管理与检查(已在 `.gitignore`、TS 与 ESLint 忽略)。
## 项目结构(约定)
- `src/pages`:页面路由(如首页、文章详情)。
- `src/layouts`:页面与文章的布局模版。
- `src/components`:通用组件(如 `Img.astro`)。
- `src/content`Astro Content Collections 与文章内容(`posts`)。
- `src/lib/content`:内容加载抽象层,便于未来扩展除 Markdown 以外的格式。
- `public`:公开静态资源(尽量不要放图片)。
## TypeScript 与 ESLint
- `tsconfig.json` 开启:`strict: true``noImplicitAny: true``noFallthroughCasesInSwitch: true` 等。
- ESLint(使用 Flat Config):统一使用根目录 `eslint.config.js`,不要新增 `.eslintrc.*` 文件;启用 `@typescript-eslint/no-explicit-any: error` 等类型感知规则,并结合 Astro 插件。
## 格式化
- Prettier 统一代码、Astro 与 Markdown 的格式。
- 命令:`pnpm format` 全量格式化,`pnpm format:check` 校验。
## 内容与多格式扩展
- 文章位于 `src/content/posts`,默认使用 Astro Content Collections`config.ts`)定义元数据 Schema。
- 已提供 `src/lib/content` 抽象:
- 通过扩展“加载器(loader)”注册新的后缀(如 `.typ`)。
- 新增格式时,请实现接口并在注册表中添加映射,无需改动现有渲染逻辑。
## 图片与外部 OSS 配置
- 使用 `PUBLIC_MEDIA_BASE_URL` 指定外部图床(OSS/CDN)前缀。
- 组件 `src/components/Img.astro` 会根据配置拼接完整图片地址。
- 若必须本地存放,建议放置在 `public/images/`,但该目录默认已在 `.gitignore` 中忽略。
## 开发与运行(建议)
- 包管理器:使用 `pnpm`(无需指定具体版本)。
- 常用脚本:
- `pnpm dev` 启动本地开发(热更新)。
- `pnpm build` 产出静态文件。
- `pnpm preview` 预览构建结果。
- `pnpm lint` 代码检查;`pnpm typecheck` 类型检查;`pnpm format` 统一格式化。
### 依赖版本策略
- 版本范围由 pnpm 写入:不要在 `package.json` 手写 `latest` 或具体版本号。
- 默认使用 caret 范围(`^x.x.x`):
- 新增依赖:`pnpm add <pkg>`(由 pnpm 写入 `^` 范围)。
- 升级依赖:`pnpm up <pkg> -L`(按范围更新到可用最新)。
- Node 版本:`>=20`(见 `package.json``engines.node`)。
- CI 构建建议使用:`pnpm install --frozen-lockfile` 保证可重复。
## 代码提交规范(简要)
- 小步提交,信息清晰:`feat: ...` / `fix: ...` / `docs: ...` / `chore: ...` 等。
- 不提交大体积二进制与图片,图片走外部 OSS。
## 注意事项
- 任何需要引入新内容格式,请优先在 `src/lib/content` 添加 loader,而不是在页面层做分支判断。
- 若需新增全局配置项,请优先考虑使用 `PUBLIC_` 前缀暴露给客户端,或通过服务器端/构建时注入。
- 路由与 getStaticPaths:对 rest 参数页面(如 `src/pages/posts/[...slug].astro`),`params.slug` 必须是字符串(包含斜杠的完整路径),不能返回数组;推荐将 `getStaticPaths` 放在独立的 TS 文件并显式标注类型(见 `src/pages/posts/getStaticPaths.ts`)。
+34
View File
@@ -0,0 +1,34 @@
# 内部学习博客(Astro + TypeScript
基于 Astro 构建的内部学习博客。目标:严格 TypeScript、统一格式化、内容可扩展(不仅 Markdown/MDX),图片优先使用外部 OSS。
## 快速开始
- 要求:Node >= 20,包管理器使用 pnpm
- 安装依赖:`pnpm install`
- 启动开发:`pnpm dev`
常用脚本:
- 构建:`pnpm build` ;预览:`pnpm preview`
- 代码检查:`pnpm lint` ;类型检查:`pnpm typecheck`
- 格式化:`pnpm format`
## 内容与结构
- 文章与集合:`src/content/`(示例:`src/content/posts/hello-world.mdx`
- 页面与布局:`src/pages/``src/layouts/`
- 通用组件:`src/components/`(如 `Img.astro` 支持外部图床)
- 内容扩展抽象:`src/lib/content/`(为未来 Typst 等格式预留)
## 配置
- `PUBLIC_MEDIA_BASE_URL`:外部图床前缀(避免图片入库)
- `PUBLIC_SITE_URL`:站点地址(可选,用于 canonical 等)
## 约定
- 严格 TypeScript:禁止 any,导出函数需显式返回类型
- 统一格式化:Prettier(含 Astro/Markdown/MDX
- 图片默认不入库,建议走外部 OSS
更多细节与约定见 `AGENTS.md`
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
export default defineConfig({
integrations: [mdx()],
site: process.env.PUBLIC_SITE_URL || 'http://localhost:4321',
});
Vendored
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_MEDIA_BASE_URL?: string;
readonly PUBLIC_SITE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+67
View File
@@ -0,0 +1,67 @@
// Flat config for ESLint v9
import tseslint from 'typescript-eslint';
import astro from 'eslint-plugin-astro';
import astroParser from 'astro-eslint-parser';
export default [
// Astro flat recommended
...astro.configs['flat/recommended'],
// TS typed linting only for TS files
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: true,
tsconfigRootDir: process.cwd(),
sourceType: 'module',
ecmaVersion: 'latest'
}
},
plugins: { '@typescript-eslint': tseslint.plugin },
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/explicit-module-boundary-types': 'error',
'@typescript-eslint/explicit-function-return-type': [
'error',
{
allowExpressions: false,
allowHigherOrderFunctions: true,
allowDirectConstAssertionInArrowFunctions: true,
allowTypedFunctionExpressions: true
}
]
}
},
// Astro files: use astro parser; do not inject TS typed rules here
{
files: ['**/*.astro'],
languageOptions: {
parser: astroParser,
parserOptions: {
parser: tseslint.parser,
project: true,
tsconfigRootDir: process.cwd(),
ecmaVersion: 'latest',
sourceType: 'module'
}
},
plugins: { '@typescript-eslint': tseslint.plugin, astro },
rules: {
// 在 .astro 文件中放宽部分 type-aware 规则,避免模板区误报
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/non-nullable-type-assertion-style': 'off'
}
},
// Ignores
{ ignores: ['dist', 'node_modules', 'eslint.config.js', '.astro', 'astro.config.*', 'ref/**'] }
];
+36
View File
@@ -0,0 +1,36 @@
{
"name": "snowflake-notes",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"lint": "eslint .",
"typecheck": "tsc -p tsconfig.json --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@astrojs/mdx": "^4.3.12",
"astro": "^5.16.1",
"zod": "^4.1.13"
},
"engines": {
"node": ">=20.0.0"
},
"packageManager": "pnpm@10.0.0+",
"devDependencies": {
"@types/node": "^24.10.1",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"astro-eslint-parser": "^1.2.2",
"eslint": "^9.39.1",
"eslint-plugin-astro": "^1.5.0",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0"
}
}
+4831
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
---
export interface Props {
src: string;
alt: string;
width?: number | string;
height?: number | string;
class?: string;
}
const { src, alt, width, height, class: className } = Astro.props;
const base = import.meta.env.PUBLIC_MEDIA_BASE_URL?.replace(/\/$/, '') ?? '';
const isAbsolute = /^(https?:)?\/\//.test(src) || src.startsWith('/');
const resolved = isAbsolute ? src : `${base}/${src}`.replace(/([^:]?)\/\/+/, '$1/');
---
<img src={resolved} alt={alt} width={width} height={height} class={className} />
+14
View File
@@ -0,0 +1,14 @@
import { defineCollection, z } from 'astro:content';
const posts = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().optional(),
pubDate: z.date().optional(),
tags: z.array(z.string()).default([])
})
});
export const collections = { posts };
+17
View File
@@ -0,0 +1,17 @@
---
title: 你好,Astro
description: 内部学习博客的第一篇文章。
pubDate: 2024-11-27
tags: ["简介", "Astro"]
---
欢迎来到新的内部学习博客!这是第一篇文章,使用 MDX 撰写。
可以直接在文中插入 React/JSX 组件或 Astro 组件。
```ts
export function add(a: number, b: number): number {
return a + b;
}
```
+28
View File
@@ -0,0 +1,28 @@
---
interface Props {
title?: string;
}
const { title } = Astro.props;
const site = import.meta.env.PUBLIC_SITE_URL ?? '';
---
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title ? `${title} | 学习博客` : '学习博客'}</title>
<link rel="canonical" href={site} />
</head>
<body>
<header style="padding: 12px; border-bottom: 1px solid #eee">
<a href="/">首页</a>
<span style="margin: 0 8px">|</span>
<a href="/posts">文章</a>
</header>
<main style="padding: 16px">
<slot />
</main>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
---
import BaseLayout from './BaseLayout.astro';
interface Props {
title: string;
description?: string;
pubDate?: Date;
}
const { title, description, pubDate } = Astro.props;
---
<BaseLayout title={title}>
<article>
<h1>{title}</h1>
{pubDate && <p style="color:#666;">{pubDate.toLocaleDateString('zh-CN')}</p>}
{description && <p>{description}</p>}
<slot />
</article>
</BaseLayout>
+23
View File
@@ -0,0 +1,23 @@
export type ContentFormat = 'md' | 'mdx' | 'typ';
export interface ContentLoader {
readonly extensions: readonly string[];
canLoad: (ext: string) => boolean;
// 未来扩展:根据路径或数据源加载内容并返回统一结构
// load: (path: string) => Promise<LoadedContent>;
}
export function createLoader(extensions: string[]): ContentLoader {
const canLoad = (ext: string): boolean => extensions.includes(ext);
return { extensions, canLoad } as const satisfies ContentLoader;
}
// 默认注册:Markdown/MDXTypst 预留
export const builtInLoaders: readonly ContentLoader[] = [
createLoader(['.md', '.mdx']),
createLoader(['.typ'])
];
export function findLoaderByExtension(ext: string): ContentLoader | undefined {
return builtInLoaders.find((l) => l.canLoad(ext));
}
+25
View File
@@ -0,0 +1,25 @@
import { getCollection, getEntry } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
export async function listAllPosts(): Promise<CollectionEntry<'posts'>[]> {
const posts = await getCollection('posts');
// 按发布日期倒序
return posts.sort((a, b) => {
const ad = a.data.pubDate ? new Date(a.data.pubDate).getTime() : 0;
const bd = b.data.pubDate ? new Date(b.data.pubDate).getTime() : 0;
return bd - ad;
});
}
export async function listRecentPosts(limit = 5): Promise<CollectionEntry<'posts'>[]> {
const all = await listAllPosts();
return all.slice(0, limit);
}
export async function getPostBySlug(slug: string): Promise<CollectionEntry<'posts'>> {
const entry = await getEntry('posts', slug);
if (!entry) {
throw new Error(`Post not found: ${slug}`);
}
return entry;
}
+15
View File
@@ -0,0 +1,15 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { listRecentPosts } from '../lib/posts';
const posts = await listRecentPosts(5);
---
<BaseLayout title="首页">
<h2>最近文章</h2>
<ul>
{posts.slice(0, 5).map((p) => (
<li>
<a href={`/posts/${p.slug}/`}>{p.data.title}</a>
</li>
))}
</ul>
</BaseLayout>
+18
View File
@@ -0,0 +1,18 @@
---
import PostLayout from '../../layouts/PostLayout.astro';
import { getEntry } from 'astro:content';
import { getStaticPaths } from './getStaticPaths';
export { getStaticPaths };
const { slug } = Astro.params as { slug: string };
const entry = await getEntry('posts', slug);
if (!entry) {
throw new Error(`Post not found: ${slug}`);
}
const { Content } = await entry.render();
const data = entry.data;
---
<PostLayout title={data.title} description={data.description} pubDate={data.pubDate}>
<Content />
</PostLayout>
+8
View File
@@ -0,0 +1,8 @@
import { getCollection } from 'astro:content';
import type { GetStaticPaths, GetStaticPathsResult } from 'astro';
// 为 rest 参数 `[...slug]` 显式声明字符串类型,防止误用数组
export const getStaticPaths: GetStaticPaths = async (): Promise<GetStaticPathsResult> => {
const posts = (await getCollection('posts')) as { slug: string }[];
return posts.map((p) => ({ params: { slug: p.slug } }));
};
+15
View File
@@ -0,0 +1,15 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { listAllPosts } from '../../lib/posts';
const posts = await listAllPosts();
---
<BaseLayout title="文章">
<h2>全部文章</h2>
<ul>
{posts.map((p) => (
<li>
<a href={`/posts/${p.slug}/`}>{p.data.title}</a> — {p.data.pubDate?.toLocaleDateString?.('zh-CN')}
</li>
))}
</ul>
</BaseLayout>
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowJs": false,
"isolatedModules": true
},
"include": [
"src",
"env.d.ts",
"astro.config.mjs",
".astro/types.d.ts"
],
"exclude": [
"dist",
"node_modules",
"ref"
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"extends": [
"astro/tsconfigs/base",
"./tsconfig.base.json"
],
"compilerOptions": {
"types": [
"astro/client"
],
"verbatimModuleSyntax": true
},
"include": [
"src",
"env.d.ts",
"astro.config.mjs",
".astro/types.d.ts"
],
"exclude": [
"dist",
"node_modules",
"ref"
]
}