Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 20 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# 静态博客系统

一个纯静态的博客系统,使用GitHub Pages托管,通过GitHub API读取markdown文件并展示为美观的网页
一个纯静态的博客系统,使用 GitHub Pages 托管,自动发现 `posts/` 目录下的 Markdown 文件并展示为美观的网页

## 功能特性

- 📝 **简单易用**:只需在 `posts/` 目录下添加 markdown 文件即可发布文章
- 📝 **简单易用**:只需在 `posts/` 目录下添加 Markdown 文件即可发布文章
- 🎨 **美观界面**:简洁现代的设计,支持响应式布局
- 🔍 **代码高亮**:支持多种编程语言的语法高亮
- 📱 **移动友好**:在各种设备上都有良好的显示效果
Expand Down Expand Up @@ -46,17 +46,23 @@ python -m http.server 8000

### 添加文章

1. 在 `posts/` 目录下创建新的 `.md` 文件
2. 在 `posts/index.json` 中添加文章信息:
```json
{
"filename": "新文章.md",
"title": "文章标题",
"date": "2024-01-20",
"description": "文章简介"
}
1. 在 `posts/` 目录下创建新的 `.md` 文件即可
2. 可选:在文件顶部加入 [YAML Front Matter](https://jekyllrb.com/docs/front-matter/) 用于自定义标题、摘要、日期等元信息,例如:
```markdown
---
title: 我的第一篇文章
description: 这是文章的简介
date: 2024-01-20
---
```
3. 刷新页面即可看到新文章
3. 推送到 GitHub 后页面会自动展示最新内容

### GitHub API 访问说明

- 页面会优先尝试直接从 `posts/` 目录加载文章列表。
- 如果托管环境(例如 GitHub Pages)不允许列出目录,则会自动回退到 GitHub 公共 API。
- 公共仓库的 API 请求无需任何 Token 或 API Key,但匿名访问存在每小时 **60 次** 的速率限制。
- 如果看到“403”或“触发速率限制”的提示,稍等片刻再刷新即可。

### 文章示例

Expand Down Expand Up @@ -84,8 +90,7 @@ console.log("代码块也支持语法高亮");
```
.
├── index.html # 博客主页
├── posts/ # 存放 markdown 文章的目录
│ ├── index.json # 文章索引配置
├── posts/ # 存放 Markdown 文章的目录
│ ├── 欢迎使用博客.md
│ └── 技术分享.md
├── serve.py # Python本地服务器
Expand All @@ -101,7 +106,7 @@ console.log("代码块也支持语法高亮");
- **前端**:原生 HTML + JavaScript
- **Markdown 解析**:marked.js (CDN)
- **代码高亮**:highlight.js (CDN)
- **数据源**:相对路径直接访问文件
- **数据源**:直接扫描 `posts/` 目录(必要时回退到 GitHub API)
- **部署**:GitHub Pages + GitHub Actions

## 自定义
Expand Down Expand Up @@ -142,7 +147,6 @@ const branch = 'master'; // 或其他分支名

3. **添加文章**:
- 在 `posts/` 目录下添加 `.md` 文件
- 在 `posts/index.json` 中添加文章信息
- 推送到 GitHub 后会自动更新网站

### 本地预览
Expand Down
226 changes: 203 additions & 23 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -133,20 +133,12 @@ <h1 id="page-title">我的博客</h1>

<script>
// ========================= 配置区域 =========================
// 文章索引文件路径
const POSTS_INDEX = './posts/index.json';
// 文章目录路径
const POSTS_DIR = './posts/';

// ========================= 工具函数 =========================
const app = document.getElementById('app');

async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`网络请求失败: ${res.status}`);
return await res.json();
}

function createElement(tag, attrs = {}, children = []) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
Expand Down Expand Up @@ -175,21 +167,212 @@ <h1 id="page-title">我的博客</h1>
}

// ========================= 列表页 =========================
const postCache = new Map();
let postsIndexCache = null;

function parseFrontMatter(markdown) {
const lines = markdown.split(/\r?\n/);
let index = 0;
const meta = {};

if (lines[0] && lines[0].trim() === '---') {
index = 1;
for (; index < lines.length; index++) {
const line = lines[index].trim();
if (line === '---') {
index++;
break;
}
if (!line || line.startsWith('#')) continue;
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const key = line.slice(0, colonIndex).trim().toLowerCase();
const value = line.slice(colonIndex + 1).trim();
meta[key] = value;
}
}

return { meta, startIndex: index };
}

function extractMetadata(markdown, filename) {
const decodedFilename = decodeURIComponent(filename);
const { meta, startIndex } = parseFrontMatter(markdown);
const lines = markdown.split(/\r?\n/);

let title = meta.title || '';
let description = meta.description || meta.summary || '';
let date = meta.date || meta.published || meta.publish_date || '';

for (let i = startIndex; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;

if (!title && line.startsWith('#')) {
title = line.replace(/^#+\s*/, '').trim();
continue;
}

if (!description && !line.startsWith('#')) {
description = line
.replace(/[`*_>#]/g, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.trim();
}

if (title && description) break;
}

if (!title) {
title = decodedFilename.replace(/\.md$/i, '');
}

if (!description) {
description = '';
}

if (!date) {
const matched = decodedFilename.match(/\d{4}-\d{2}-\d{2}/);
if (matched) {
date = matched[0];
}
}

return { title, description, date };
}

function getGitHubRepoInfo() {
const { hostname, pathname } = window.location;
if (!hostname.endsWith('github.io')) return null;

const owner = hostname.replace('.github.io', '');
const segments = pathname.split('/').filter(Boolean);

if (segments.length === 0) {
return { owner, repo: `${owner}.github.io` };
}

return { owner, repo: segments[0] };
}

async function fetchText(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`网络请求失败: ${res.status}`);
}
return await res.text();
}

async function loadMetadata(filename, url) {
const markdown = await fetchText(url);
postCache.set(filename, { content: markdown, url });
const metadata = extractMetadata(markdown, filename);
return { filename, url, ...metadata };
}

async function listPostsFromDirectory() {
const directoryUrl = POSTS_DIR;
const res = await fetch(directoryUrl);
if (!res.ok) {
throw new Error(`目录访问失败: ${res.status}`);
}

const html = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const links = Array.from(doc.querySelectorAll('a'))
.map(a => decodeURIComponent(a.getAttribute('href') || ''))
.filter(href => href.toLowerCase().endsWith('.md'))
.map(href => href.split('/').filter(Boolean).pop())
.filter(Boolean);

const unique = Array.from(new Set(links));
if (unique.length === 0) {
return [];
}

return Promise.all(unique.map(filename => loadMetadata(filename, POSTS_DIR + filename)));
}

async function listPostsFromGitHub() {
const repoInfo = getGitHubRepoInfo();
if (!repoInfo) {
throw new Error('无法自动识别 GitHub 仓库信息');
}

const apiUrl = `https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}/contents/posts`;
// GitHub 的公共 API 可以匿名访问,无需 token,但存在每小时 60 次的速率限制。
const res = await fetch(apiUrl);
if (!res.ok) {
if (res.status === 403) {
throw new Error('GitHub API 请求失败: 403(匿名访问触发速率限制,稍后再试即可)');
}
throw new Error(`GitHub API 请求失败: ${res.status}`);
}

const data = await res.json();
const files = (Array.isArray(data) ? data : []).filter(item => item.type === 'file' && /\.md$/i.test(item.name));

return Promise.all(files.map(file => loadMetadata(file.name, file.download_url)));
}

async function fetchPostList() {
if (postsIndexCache) return postsIndexCache;

try {
const localPosts = await listPostsFromDirectory();
postsIndexCache = localPosts;
return postsIndexCache;
} catch (err) {
console.warn('从本地目录读取文章列表失败,将尝试使用 GitHub API。', err);
}

const githubPosts = await listPostsFromGitHub();
postsIndexCache = githubPosts;
return postsIndexCache;
}

async function fetchPostContent(filename) {
if (postCache.has(filename)) {
return postCache.get(filename).content;
}

const posts = await fetchPostList();
const post = posts.find(p => p.filename === filename);
const url = post ? post.url : POSTS_DIR + filename;
const markdown = await fetchText(url);
postCache.set(filename, { content: markdown, url });
return markdown;
}

function sortPosts(posts) {
return posts.slice().sort((a, b) => {
if (a.date && b.date) {
const diff = new Date(b.date) - new Date(a.date);
if (!Number.isNaN(diff)) {
return diff;
}
}
return a.title.localeCompare(b.title, 'zh');
});
}

async function renderPostList() {
try {
const posts = await fetchJSON(POSTS_INDEX);
const posts = await fetchPostList();
const sortedPosts = sortPosts(posts);

if (posts.length === 0) {
setContent(createElement('div', { class: 'no-posts', html: '还没有文章。请在 <code>posts/</code> 目录下添加 markdown 文件并更新 index.json。' }));
if (sortedPosts.length === 0) {
setContent(createElement('div', { class: 'no-posts', html: '还没有文章。请在 <code>posts/</code> 目录下添加 markdown 文件。' }));
return;
}

const list = createElement('ul', { class: 'post-list' });
posts.forEach(post => {
sortedPosts.forEach(post => {
const li = createElement('li', { class: 'post-item' });
const link = createElement('a', { href: `?file=${encodeURIComponent(post.filename)}` }, post.title);
li.appendChild(createElement('div', { class: 'post-title' }, link));

// 添加描述和日期
const meta = createElement('div', { class: 'post-meta' });
if (post.description) {
Expand All @@ -206,17 +389,15 @@ <h1 id="page-title">我的博客</h1>
setContent(list);
} catch (err) {
console.error(err);
setContent(createElement('div', { class: 'no-posts', html: '获取文章列表失败。请检查 posts/index.json 文件是否存在。' }));
const message = `获取文章列表失败:${err.message || err}`;
setContent(createElement('div', { class: 'no-posts', html: `${message}<br>请检查 <code>posts/</code> 目录或稍后重试。` }));
}
}

// ========================= 文章页 =========================
async function renderPost(filename) {
try {
const postUrl = POSTS_DIR + filename;
const res = await fetch(postUrl);
if (!res.ok) throw new Error('文章加载失败');
const markdown = await res.text();
const markdown = await fetchPostContent(filename);

const htmlContent = marked.parse(markdown, {
highlight: function(code, lang) {
Expand All @@ -226,12 +407,11 @@ <h1 id="page-title">我的博客</h1>
return hljs.highlightAuto(code).value;
}
});

// 获取文章标题
const posts = await fetchJSON(POSTS_INDEX);

const posts = await fetchPostList();
const post = posts.find(p => p.filename === filename);
const title = post ? post.title : filename.replace(/\.md$/, '');
const title = post ? post.title : decodeURIComponent(filename).replace(/\.md$/, '');

const container = createElement('div', {}, [
createElement('a', { class: 'back-link', href: './' }, '← 返回首页'),
createElement('article', { class: 'content', html: htmlContent })
Expand Down