Initialize blog scaffold

Add the CLI, site, and sample content so the project can run locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yarnom 2026-05-28 16:58:30 +08:00
parent 9d2628b318
commit b78f4b39c9
40 changed files with 9140 additions and 0 deletions

View file

@ -0,0 +1,6 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'static',
outDir: '../../dist/site'
});

5591
frontend/site/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
{
"name": "@osaet/site",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/rss": "^4.0.12",
"astro": "^5.0.0",
"yaml": "^2.8.0"
},
"devDependencies": {}
}

View file

@ -0,0 +1,14 @@
---
const links = [
{ href: '/', label: 'Home' },
{ href: '/archive/', label: 'Archive' },
{ href: '/tags/', label: 'Tags' },
{ href: '/rss.xml', label: 'RSS' }
];
---
<nav class="site-nav" aria-label="Primary">
{links.map((link) => (
<a href={link.href}>{link.label}</a>
))}
</nav>

View file

@ -0,0 +1,89 @@
type MarkdownPost = {
frontmatter: {
slug: string;
title: string;
summary?: string;
status?: string;
tags?: string[];
published_at?: string;
updated_at?: string;
};
};
export type Post = MarkdownPost['frontmatter'] & {
url: string;
date: string;
tags: string[];
};
export type TagSummary = {
name: string;
slug: string;
count: number;
};
export type ArchiveYear = {
year: string;
posts: Post[];
};
export function getPublishedPosts(): Post[] {
const modules = import.meta.glob('../../../../content/posts/*.md', { eager: true });
return Object.values(modules)
.map((post) => {
const frontmatter = (post as MarkdownPost).frontmatter;
return {
...frontmatter,
url: `/posts/${frontmatter.slug}/`,
date: frontmatter.published_at ?? frontmatter.updated_at ?? '',
tags: frontmatter.tags ?? []
};
})
.filter((post) => post.status === 'published')
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
}
export function tagSlug(tag: string): string {
return encodeURIComponent(tag.trim().toLowerCase().replace(/\s+/g, '-'));
}
export function getTagSummaries(): TagSummary[] {
const counts = new Map<string, TagSummary>();
for (const post of getPublishedPosts()) {
for (const tag of post.tags) {
const name = tag.trim();
if (!name) {
continue;
}
const slug = tagSlug(name);
const current = counts.get(slug);
if (current) {
current.count += 1;
} else {
counts.set(slug, { name, slug, count: 1 });
}
}
}
return [...counts.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export function getPostsByTag(slug: string): Post[] {
return getPublishedPosts().filter((post) => post.tags.some((tag) => tagSlug(tag) === slug));
}
export function getArchiveYears(): ArchiveYear[] {
const years = new Map<string, Post[]>();
for (const post of getPublishedPosts()) {
const year = post.date ? String(new Date(post.date).getFullYear()) : 'Undated';
years.set(year, [...(years.get(year) ?? []), post]);
}
return [...years.entries()]
.sort(([a], [b]) => b.localeCompare(a))
.map(([year, posts]) => ({ year, posts }));
}

View file

@ -0,0 +1,60 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import YAML from 'yaml';
const siteRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
type SiteConfig = {
site: {
title: string;
description: string;
base_url: string;
language: string;
timezone: string;
};
content: {
posts_dir: string;
assets_dir: string;
};
build: {
astro_project: string;
output_dir: string;
};
};
const defaults: SiteConfig = {
site: {
title: 'Osaet',
description: 'Personal blog',
base_url: 'http://localhost:4321',
language: 'zh-CN',
timezone: 'Asia/Shanghai'
},
content: {
posts_dir: 'content/posts',
assets_dir: 'content/assets'
},
build: {
astro_project: 'frontend/site',
output_dir: 'dist/site'
}
};
function loadSiteConfig(): SiteConfig {
try {
const file = readFileSync(resolve(siteRoot, 'config/site.yaml'), 'utf8');
const parsed = YAML.parse(file) ?? {};
return {
site: { ...defaults.site, ...parsed.site },
content: { ...defaults.content, ...parsed.content },
build: { ...defaults.build, ...parsed.build }
};
} catch {
return defaults;
}
}
export const siteConfig = loadSiteConfig();
export const site = siteConfig.site;

View file

@ -0,0 +1,64 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import { site } from '../../lib/siteConfig';
import { getArchiveYears, tagSlug } from '../../lib/posts';
const archiveYears = getArchiveYears();
---
<!doctype html>
<html lang={site.language}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Archive | {site.title}</title>
<meta name="description" content={`Archive of ${site.title}`} />
</head>
<body>
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.title}</p>
<h1>Archive</h1>
</header>
{archiveYears.length === 0 ? (
<p class="empty">No published posts yet.</p>
) : (
<div class="archive-list">
{archiveYears.map((group) => (
<section class="archive-year" aria-label={group.year}>
<h2>{group.year}</h2>
<ol>
{group.posts.map((post) => (
<li>
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language, {
month: '2-digit',
day: '2-digit'
})}
</time>
)}
<a href={post.url}>{post.title}</a>
{post.tags.length > 0 && (
<ul class="tags compact-tags" aria-label="Tags">
{post.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</li>
))}
</ol>
</section>
))}
</div>
)}
</main>
</body>
</html>

View file

@ -0,0 +1,59 @@
---
import '../styles/global.css';
import SiteNav from '../components/SiteNav.astro';
import { site } from '../lib/siteConfig';
import { getPublishedPosts, tagSlug } from '../lib/posts';
const posts = getPublishedPosts();
---
<!doctype html>
<html lang={site.language}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{site.title}</title>
<meta name="description" content={site.description} />
</head>
<body>
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.description}</p>
<h1>{site.title}</h1>
</header>
<section class="post-list" aria-label="Posts">
{posts.length === 0 ? (
<p class="empty">No published posts yet.</p>
) : (
posts.map((post) => (
<article class="post-item">
<a href={post.url}>
<h2>{post.title}</h2>
{post.summary && <p>{post.summary}</p>}
<div class="post-meta">
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language)}
</time>
)}
{post.tags.length > 0 && (
<ul class="tags" aria-label="Tags">
{post.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</div>
</a>
</article>
))
)}
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,56 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import { site } from '../../lib/siteConfig';
import { tagSlug } from '../../lib/posts';
export function getStaticPaths() {
const modules = import.meta.glob('../../../../../content/posts/*.md', { eager: true });
return Object.values(modules)
.filter((post) => post.frontmatter.status === 'published')
.map((post) => ({
params: { slug: post.frontmatter.slug },
props: { post }
}));
}
const { post } = Astro.props;
const { Content } = post;
const title = post.frontmatter.title;
---
<!doctype html>
<html lang={site.language}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title} | {site.title}</title>
{post.frontmatter.summary && <meta name="description" content={post.frontmatter.summary} />}
</head>
<body>
<main class="page article-page">
<SiteNav />
<article class="article">
<header>
<h1>{title}</h1>
<time datetime={post.frontmatter.published_at ?? post.frontmatter.updated_at}>
{new Date(post.frontmatter.published_at ?? post.frontmatter.updated_at).toLocaleDateString(site.language)}
</time>
{post.frontmatter.summary && <p class="summary">{post.frontmatter.summary}</p>}
{post.frontmatter.tags?.length > 0 && (
<ul class="tags article-tags" aria-label="Tags">
{post.frontmatter.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</header>
<Content />
</article>
</main>
</body>
</html>

View file

@ -0,0 +1,18 @@
import rss from '@astrojs/rss';
import { getPublishedPosts } from '../lib/posts';
import { site } from '../lib/siteConfig';
export function GET() {
return rss({
title: site.title,
description: site.description,
site: site.base_url,
items: getPublishedPosts().map((post) => ({
title: post.title,
description: post.summary ?? '',
link: post.url,
pubDate: post.date ? new Date(post.date) : undefined,
categories: post.tags
}))
});
}

View file

@ -0,0 +1,52 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import { site } from '../../lib/siteConfig';
import { getPostsByTag, getTagSummaries } from '../../lib/posts';
export function getStaticPaths() {
return getTagSummaries().map((tag) => ({
params: { tag: tag.slug },
props: { tag }
}));
}
const { tag } = Astro.props;
const posts = getPostsByTag(tag.slug);
---
<!doctype html>
<html lang={site.language}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{tag.name} | {site.title}</title>
<meta name="description" content={`Posts tagged ${tag.name}`} />
</head>
<body>
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.title}</p>
<h1>{tag.name}</h1>
</header>
<section class="post-list" aria-label={`Posts tagged ${tag.name}`}>
{posts.map((post) => (
<article class="post-item">
<a href={post.url}>
<h2>{post.title}</h2>
{post.summary && <p>{post.summary}</p>}
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language)}
</time>
)}
</a>
</article>
))}
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,43 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import { site } from '../../lib/siteConfig';
import { getTagSummaries } from '../../lib/posts';
const tags = getTagSummaries();
---
<!doctype html>
<html lang={site.language}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Tags | {site.title}</title>
<meta name="description" content={`Tags on ${site.title}`} />
</head>
<body>
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.title}</p>
<h1>Tags</h1>
</header>
{tags.length === 0 ? (
<p class="empty">No tags yet.</p>
) : (
<ul class="tag-index" aria-label="Tags">
{tags.map((tag) => (
<li>
<a href={`/tags/${tag.slug}/`}>
<span>{tag.name}</span>
<strong>{tag.count}</strong>
</a>
</li>
))}
</ul>
)}
</main>
</body>
</html>

View file

@ -0,0 +1,240 @@
:root {
color-scheme: light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
background: #f7f5ef;
color: #20201d;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
color: inherit;
}
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
}
.site-nav {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.site-nav a {
text-decoration: none;
}
.site-nav a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
.site-header {
border-bottom: 1px solid #d8d1c3;
padding-bottom: 28px;
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: #6f675b;
font-size: 0.875rem;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2.4rem, 8vw, 5rem);
line-height: 1;
}
.post-list {
display: grid;
gap: 16px;
}
.post-item {
border-bottom: 1px solid #ded8cd;
padding: 18px 0 22px;
}
.post-item a {
display: block;
text-decoration: none;
}
.post-item h2 {
margin: 0 0 8px;
font-size: 1.45rem;
}
.post-item p,
.summary,
.empty {
color: #5b554d;
}
.post-item p {
margin: 0 0 12px;
line-height: 1.7;
}
time {
color: #7a7268;
font-size: 0.9rem;
}
.post-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
align-items: center;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0;
margin: 0;
list-style: none;
}
.tags li {
border: 1px solid #d0c8bb;
border-radius: 999px;
color: #5f584d;
font-size: 0.82rem;
}
.tags a {
display: inline-block;
padding: 2px 8px;
text-decoration: none;
}
.tag-index {
display: grid;
gap: 10px;
padding: 0;
margin: 0;
list-style: none;
}
.tag-index a {
display: flex;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid #ded8cd;
padding: 14px 0;
text-decoration: none;
}
.tag-index strong {
color: #766f65;
font-weight: 500;
}
.back-nav {
margin-bottom: 32px;
}
.back-nav a {
color: #635d53;
text-decoration: none;
}
.article {
line-height: 1.8;
}
.article header {
border-bottom: 1px solid #d8d1c3;
margin-bottom: 32px;
padding-bottom: 24px;
}
.article-tags {
margin-top: 16px;
}
.archive-list {
display: grid;
gap: 32px;
}
.archive-year h2 {
margin: 0 0 12px;
font-size: 1.4rem;
}
.archive-year ol {
display: grid;
gap: 12px;
padding: 0;
margin: 0;
list-style: none;
}
.archive-year li {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
border-bottom: 1px solid #ded8cd;
padding: 10px 0;
}
.archive-year li > a {
text-decoration: none;
}
.compact-tags {
justify-content: flex-end;
}
@media (max-width: 640px) {
.archive-year li {
grid-template-columns: 1fr;
gap: 6px;
}
.compact-tags {
justify-content: flex-start;
}
}
.article h1 {
margin-bottom: 16px;
font-size: clamp(2.1rem, 7vw, 4rem);
}
.article h2 {
margin-top: 2em;
}
.article pre {
overflow-x: auto;
border-radius: 8px;
padding: 16px;
background: #272822;
color: #f8f8f2;
}
.article code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}