feat: add admin publishing workflow and yar theme

Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
This commit is contained in:
yarnom 2026-06-01 15:48:04 +08:00
parent b78f4b39c9
commit f0b50d13ea
121 changed files with 27139 additions and 550 deletions

View file

@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View file

@ -0,0 +1,177 @@
---
import SiteNav from './SiteNav.astro';
import { site } from '../lib/siteConfig';
import { tagSlug, type ArchiveYear } from '../lib/posts';
type Props = {
archiveYears: ArchiveYear[];
};
const { archiveYears } = Astro.props;
---
<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>
<style>
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.page :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.page :global(.site-nav a),
.archive-year li > a,
.tags a {
color: inherit;
text-decoration: none;
}
.page :global(.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;
}
.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;
}
time {
color: #7a7268;
font-size: 0.9rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
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;
}
.empty {
color: #5b554d;
}
@media (max-width: 640px) {
.archive-year li {
grid-template-columns: 1fr;
gap: 6px;
}
.tags {
justify-content: flex-start;
}
}
</style>

View file

@ -0,0 +1,165 @@
---
import SiteNav from './SiteNav.astro';
import { site } from '../lib/siteConfig';
import { tagSlug, type Post } from '../lib/posts';
type Props = {
posts: Post[];
};
const { posts } = Astro.props;
---
<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>
<style>
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.page :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.page :global(.site-nav a),
.post-item a,
.tags a {
color: inherit;
text-decoration: none;
}
.page :global(.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;
}
.post-item h2 {
margin: 0 0 8px;
font-size: 1.45rem;
}
.post-item p,
.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;
}
</style>

View file

@ -0,0 +1,130 @@
---
import SiteNav from './SiteNav.astro';
import { site } from '../lib/siteConfig';
import { tagSlug } from '../lib/posts';
type Props = {
post: any;
};
const { post } = Astro.props;
const { Content } = post;
const title = post.frontmatter.title;
const date = post.frontmatter.created_at ?? post.frontmatter.published_at ?? post.frontmatter.updated_at;
---
<main class="page article-page">
<SiteNav />
<article class="article">
<header>
<h1>{title}</h1>
{date && (
<time datetime={date}>
{new Date(date).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>
<style>
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.page :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.page :global(.site-nav a),
.tags a {
color: inherit;
text-decoration: none;
}
.page :global(.site-nav a:hover) {
text-decoration: underline;
text-underline-offset: 4px;
}
.article {
line-height: 1.8;
}
.article header {
border-bottom: 1px solid #d8d1c3;
margin-bottom: 32px;
padding-bottom: 24px;
}
.article h1 {
margin: 0 0 16px;
font-size: clamp(2.1rem, 7vw, 4rem);
}
.summary {
color: #5b554d;
}
time {
color: #7a7268;
font-size: 0.9rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0;
margin: 16px 0 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;
}
.article :global(pre) {
overflow-x: auto;
border-radius: 8px;
padding: 16px;
background: #272822;
color: #f8f8f2;
}
.article :global(code) {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
</style>

View file

@ -0,0 +1,118 @@
---
import SiteNav from './SiteNav.astro';
import { site } from '../lib/siteConfig';
import type { Post, TagSummary } from '../lib/posts';
type Props = {
tag: TagSummary;
posts: Post[];
};
const { tag, posts } = Astro.props;
---
<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>
<style>
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.page :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.page :global(.site-nav a),
.post-item a {
color: inherit;
text-decoration: none;
}
.page :global(.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;
}
.post-item h2 {
margin: 0 0 8px;
font-size: 1.45rem;
}
.post-item p {
margin: 0 0 12px;
color: #5b554d;
line-height: 1.7;
}
time {
color: #7a7268;
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,111 @@
---
import SiteNav from './SiteNav.astro';
import { site } from '../lib/siteConfig';
import type { TagSummary } from '../lib/posts';
type Props = {
tags: TagSummary[];
};
const { tags } = Astro.props;
---
<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>
<style>
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.page :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.page :global(.site-nav a),
.tag-index a {
color: inherit;
text-decoration: none;
}
.page :global(.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;
}
.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;
}
.tag-index strong {
color: #766f65;
font-weight: 500;
}
.empty {
color: #5b554d;
}
</style>

View file

@ -1,8 +1,8 @@
---
const links = [
{ href: '/', label: 'Home' },
{ href: '/archive/', label: 'Archive' },
{ href: '/tags/', label: 'Tags' },
{ href: '/', label: '首页' },
{ href: '/archive/', label: '归档' },
{ href: '/tags/', label: '标签' },
{ href: '/rss.xml', label: 'RSS' }
];
---

View file

@ -0,0 +1,346 @@
---
import SiteNav from '../../SiteNav.astro';
import { site } from '../../../lib/siteConfig';
import { tagSlug, type ArchiveYear } from '../../../lib/posts';
type Props = {
archiveYears: ArchiveYear[];
};
const { archiveYears } = Astro.props;
---
<main class="yar-archive-page">
<div class="yar-shell">
<header class="yar-header">
<div class="yar-header-inner">
<div class="yar-title-box">
<h1 class="yar-title">{site.title}</h1>
</div>
<SiteNav />
</div>
</header>
<section class="yar-archive" aria-label="Archive">
{archiveYears.length === 0 ? (
<p class="yar-empty">No published posts yet.</p>
) : (
<div class="yar-archive-list">
{archiveYears.map((group) => (
<section class="yar-archive-year" aria-label={group.year}>
<div class="yar-archive-year-label">
<div class="yar-archive-year-meta">
<h3>{group.year}</h3>
<p>共 {group.posts.length} 篇</p>
</div>
</div>
<ol>
{group.posts.map((post) => (
<li>
<time datetime={post.date}>
{post.date
? new Date(post.date).toLocaleDateString(site.language, {
month: '2-digit',
day: '2-digit'
})
: '----'}
</time>
<a class="yar-archive-post" href={post.url}>{post.title}</a>
{post.tags.length > 0 && (
<ul class="yar-tags" aria-label="Tags">
{post.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</li>
))}
</ol>
</section>
))}
</div>
)}
</section>
<footer class="yar-footer">
<p>由 osaet 构建</p>
<p>© 2026 Osaet. All rights reserved.</p>
</footer>
</div>
</main>
<style>
.yar-archive-page {
min-height: 100vh;
background: #ffffff;
color: #1c211b;
}
.yar-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
width: 100%;
padding: 40px 0 5vh;
}
.yar-header {
border-bottom: 1px solid rgba(28, 33, 27, 0.18);
}
.yar-header-inner {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
width: 50vw;
margin: 0 auto;
padding-bottom: 28px;
}
.yar-shell :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
justify-content: flex-end;
margin: 0;
border-radius: 0.75em;
background: #ffffff;
box-shadow: 0 0em 0.2em rgb(29 53 87 / 13%);
padding: 0.5em 1.4em;
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
}
.yar-shell :global(.site-nav a) {
border-radius: 0.65em;
color: inherit;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-shell :global(.site-nav a:hover) {
background: #eef1ed;
text-decoration: none;
}
.yar-title-box {
border-radius: 0.7em;
background: #1d3557;
padding: 1em 1.4em;
}
.yar-title {
margin: 0;
color: #ffffff;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-size: 1em;
letter-spacing: 0.16em;
line-height: 1.15;
}
.yar-archive {
width: 100%;
margin: 0;
padding: 36px 0 0;
color: #3d3d3f;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
font-size: 16px;
letter-spacing: 1px;
line-height: 1.7em;
}
.yar-archive-list {
display: grid;
gap: 3.2em 0;
}
.yar-archive-year {
display: grid;
grid-template-columns: 25% 50% 25%;
}
.yar-archive-year-label {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
}
.yar-archive-year-meta {
display: grid;
gap: 0.35em;
align-content: start;
justify-items: center;
}
.yar-archive-year h3 {
margin: 0;
color: #8a9089;
font-size: 1em;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.7em;
}
.yar-archive-year-label p {
margin: 0;
color: #b0b5ae;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.72rem;
line-height: 1.4;
}
.yar-archive-year ol {
display: grid;
gap: 0.8em;
grid-column: 2;
padding: 0;
margin: 0;
list-style: none;
}
.yar-archive-year > ol > li {
display: grid;
grid-template-columns: 5.5em minmax(0, 1fr) auto;
gap: 1em;
align-items: flex-start;
border-bottom: 0;
padding: 0;
}
.yar-archive-year time {
color: #7a8375;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.7em;
white-space: nowrap;
}
.yar-archive-post {
color: #1c211b;
line-height: 1.5;
text-decoration: none;
}
.yar-archive-post:hover {
color: #1d3557;
text-decoration: underline;
text-decoration-color: rgb(29 53 87 / 32%);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
.yar-tags {
display: flex;
flex-wrap: wrap;
gap: 0.45em;
justify-content: flex-end;
padding: 0;
margin: 0;
list-style: none;
}
.yar-tags li {
display: inline-flex;
}
.yar-tags a {
border-radius: 999em;
background: #f3f6f4;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 7%);
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.72rem;
line-height: 1;
padding: 0.5em 0.75em;
text-decoration: none;
}
.yar-tags a:hover {
background: #e8eee9;
}
.yar-empty {
margin: 0;
color: #596254;
text-align: center;
}
.yar-footer {
width: 50vw;
margin: 2em auto 0;
color: #8a9089;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.8;
text-align: center;
}
.yar-footer p {
margin: 0;
}
@media (max-width: 760px) {
.yar-shell {
padding-top: 24px;
}
.yar-header-inner,
.yar-footer {
width: calc(100% - 28px);
}
.yar-header-inner {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.yar-archive {
width: calc(100% - 28px);
margin: 0 auto;
}
.yar-archive-year {
grid-template-columns: 1fr;
gap: 0.8em;
}
.yar-archive-year-label {
justify-content: flex-start;
padding-right: 0;
}
.yar-archive-year ol {
grid-column: auto;
}
.yar-archive-year > ol > li {
grid-template-columns: 1fr;
gap: 0.55em;
}
.yar-tags {
justify-content: flex-start;
}
}
</style>

View file

@ -0,0 +1,476 @@
---
import SiteNav from '../../SiteNav.astro';
import { site } from '../../../lib/siteConfig';
import { tagSlug, type Post } from '../../../lib/posts';
type Props = {
posts: Post[];
currentPage?: number;
totalPages?: number;
};
const { posts, currentPage = 1, totalPages = 1 } = Astro.props;
const pages = Array.from({ length: totalPages }, (_, index) => index + 1);
function pageHref(page: number): string {
return page === 1 ? '/' : `/page/${page}/`;
}
function formatPublishedAt(date: string): string {
return new Date(date).toLocaleString(site.language, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
---
<main class="yar-home">
<div class="yar-shell">
<header class="yar-header">
<div class="yar-header-inner">
<div class="yar-title-box">
<h1 class="yar-title">{site.title}</h1>
</div>
<SiteNav />
</div>
</header>
<section class="yar-section" aria-label="Posts">
<div class="yar-posts">
{posts.length === 0 ? (
<p class="yar-empty">No published posts yet.</p>
) : (
posts.map((post) => {
const Content = post.Content;
return (
<article class="yar-post">
<header class="yar-post-header">
<a href={post.url}>
<div class="heti heti--sans">
<h2 class="post-title">{post.title}</h2>
</div>
</a>
{post.date && (
<time class="yar-date" datetime={post.date}>
发布时间:{formatPublishedAt(post.date)}
</time>
)}
</header>
<div class="yar-post-content heti heti--sans">
<Content />
</div>
{post.tags.length > 0 && (
<ul class="yar-tags" aria-label="Tags">
{post.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</article>
);
})
)}
</div>
</section>
{totalPages > 1 && (
<nav class="yar-pagination" aria-label="Pagination">
{currentPage > 1 && (
<a href={pageHref(currentPage - 1)} aria-label="上一页">上一页</a>
)}
<ol>
{pages.map((page) => (
<li>
<a
href={pageHref(page)}
class:list={['yar-page-link', { 'is-current': page === currentPage }]}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</a>
</li>
))}
</ol>
{currentPage < totalPages && (
<a href={pageHref(currentPage + 1)} aria-label="下一页">下一页</a>
)}
</nav>
)}
<footer class="yar-footer">
<p>由 osaet 构建</p>
<p>© 2026 Osaet. All rights reserved.</p>
</footer>
</div>
</main>
<style>
.yar-home {
min-height: 100vh;
background: #ffffff;
color: #1c211b;
}
.yar-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
width: 100%;
padding: 40px 0 5vh;
}
.yar-header {
border-bottom: 1px solid rgba(28, 33, 27, 0.18);
}
.yar-header-inner {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
width: 50vw;
margin: 0 auto;
padding-bottom: 28px;
}
.yar-shell :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
justify-content: flex-end;
margin: 0;
border-radius: 0.75em;
background: #ffffff;
box-shadow: 0 0em 0.2em rgb(29 53 87 / 13%);
padding: 0.5em 1.4em;
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
}
.yar-shell :global(.site-nav a) {
border-radius: 0.65em;
color: inherit;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-shell :global(.site-nav a:hover) {
background: #eef1ed;
text-decoration: none;
}
.heti {
max-width: 42em;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
font-size: 16px;
line-height: 1.9;
letter-spacing: 0;
overflow-wrap: break-word;
text-align: justify;
}
.heti h1,
.heti h2,
.heti h3,
.heti h4,
.heti h5,
.heti h6 {
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-weight: 700;
line-height: 1.35;
text-align: start;
}
.heti--sans {
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
}
.heti a {
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
}
.heti img {
max-width: 100%;
height: auto;
}
.yar-title-box {
border-radius: 0.7em;
background: #1d3557;
padding: 1em 1.4em;
}
.yar-title {
margin: 0;
color: #ffffff;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-size: 1em;
letter-spacing: 0.16em;
line-height: 1.15;
}
.yar-section {
width: 50vw;
margin: 0 auto;
padding: 36px 0 0;
}
.yar-posts {
display: grid;
gap: 0;
}
.yar-post {
color: #3d3d3f;
font-size: 16px;
line-height: 1.7em;
border-bottom: 2px dashed #eee;
letter-spacing: 1px;
padding-bottom: 1.5em;
margin-bottom: 3.5em;
}
.yar-post + .yar-post {
padding-top: 0;
}
.yar-post-header a {
display: inline-block;
color: inherit;
text-decoration: none;
}
.post-title {
margin: 0 0 8px;
line-height: 1.2;
}
.yar-post-content {
margin-top: 12px;
}
.yar-post-content :global(p:first-child) {
margin-top: 0;
}
.yar-post-content :global(p:last-child) {
margin-bottom: 0;
}
.yar-post-content :global(img) {
display: block;
border-radius: 0.7em;
max-width: 100%;
height: auto;
margin: 1.2em auto;
}
.yar-post-content :global(hr) {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border: 0;
margin: 2em 0;
color: #0003;
font-size: 1.1em;
text-align: center;
}
.yar-post-content :global(hr::before) {
content: "* * *";
}
.yar-post-content :global(pre) {
overflow-x: auto;
border-radius: 0.8em;
background: #f6f8fa;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 8%);
color: #263238;
font-size: 0.92em;
line-height: 1.7;
margin: 1.4em 0;
padding: 1em 1.2em;
}
.yar-post-content :global(pre code) {
background: transparent;
color: inherit;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: inherit;
line-height: inherit;
padding: 0;
}
.yar-post-content :global(:not(pre) > code) {
border-radius: 0.35em;
background: #f3f6f4;
color: #1d3557;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
padding: 0.15em 0.35em;
}
.yar-post-content :global(a) {
color: #1d3557;
font-weight: 600;
text-decoration: underline;
text-decoration-color: rgb(29 53 87 / 24%);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
.yar-post-content :global(a:hover) {
background: #f3f6f4;
text-decoration-color: rgb(29 53 87 / 48%);
}
.yar-post p {
margin: 0;
color: #596254;
line-height: 1.8;
}
.yar-date {
display: block;
margin-top: 0.45em;
color: #7a8375;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
white-space: nowrap;
}
.yar-tags {
display: flex;
flex-wrap: wrap;
gap: 0.55em;
padding: 1em 0 0;
margin: 0;
list-style: none;
}
.yar-tags li {
display: inline-flex;
}
.yar-tags a {
border-radius: 999em;
background: #f3f6f4;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 7%);
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-tags a:hover {
background: #e8eee9;
}
.yar-empty {
margin: 0;
color: #596254;
}
.yar-pagination {
display: flex;
justify-content: center;
gap: 0.7em;
align-items: center;
width: 50vw;
margin: 0 auto 3.5em;
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
}
.yar-pagination ol {
display: flex;
gap: 0.45em;
padding: 0;
margin: 0;
list-style: none;
}
.yar-pagination a {
border-radius: 0.7em;
color: inherit;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-pagination a:hover,
.yar-pagination .is-current {
background: #eef1ed;
}
.yar-footer {
width: 50vw;
margin: auto auto 0;
color: #8a9089;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.8;
text-align: center;
}
.yar-footer p {
margin: 0;
}
@media (max-width: 760px) {
.yar-shell {
padding-top: 24px;
}
.yar-header-inner,
.yar-section,
.yar-pagination,
.yar-footer {
width: calc(100% - 28px);
}
.yar-header-inner {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.yar-date {
white-space: normal;
}
}
</style>

View file

@ -0,0 +1,331 @@
---
import { site } from '../../../lib/siteConfig';
import { tagSlug } from '../../../lib/posts';
type Props = {
post: any;
};
const { post } = Astro.props;
const { Content } = post;
const date = post.frontmatter.created_at ?? post.frontmatter.published_at ?? post.frontmatter.updated_at;
function formatPublishedAt(value: string): string {
return new Date(value).toLocaleString(site.language, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
---
<main class="yar-page">
<div class="yar-shell">
<div class="yar-back-row">
<a class="yar-back" href="/" data-history-back>← Back</a>
</div>
<article class="yar-article">
<header class="yar-article-header">
<div class="heti heti--sans">
<h1 class="post-title">{post.frontmatter.title}</h1>
</div>
{date && (
<time class="yar-date" datetime={date}>
{formatPublishedAt(date)}
</time>
)}
{post.frontmatter.tags?.length > 0 && (
<ul class="yar-tags" aria-label="Tags">
{post.frontmatter.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</header>
<div class="yar-post-content heti heti--sans">
<Content />
</div>
</article>
<footer class="yar-footer">
<p>由 osaet 构建</p>
<p>© 2026 Osaet. All rights reserved.</p>
</footer>
</div>
</main>
<style>
.yar-page {
min-height: 100vh;
background: #ffffff;
color: #1c211b;
}
.yar-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
width: 100%;
padding: 40px 0 5vh;
}
.yar-back-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1em;
width: 50vw;
margin: 0 auto;
}
.yar-back {
color: #1d3557;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
font-weight: 600;
text-decoration: underline;
text-decoration-color: rgb(29 53 87 / 24%);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
.yar-back:hover {
background: #f3f6f4;
text-decoration-color: rgb(29 53 87 / 48%);
}
.heti,
.heti--sans {
max-width: 42em;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
font-size: 16px;
line-height: 1.9;
letter-spacing: 0;
overflow-wrap: break-word;
text-align: justify;
}
.heti h1,
.heti h2,
.heti h3,
.heti h4,
.heti h5,
.heti h6 {
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-weight: 700;
line-height: 1.35;
text-align: start;
}
.yar-article {
width: 50vw;
margin: 0 auto;
padding: 36px 0 0;
color: #3d3d3f;
font-size: 16px;
line-height: 1.7em;
letter-spacing: 1px;
}
.yar-article-header {
text-align: center;
}
.yar-article-header .heti {
max-width: none;
text-align: center;
}
.yar-article-header .post-title {
margin: 0 0 8px;
font-size: 1.75em;
letter-spacing: 0.06em;
line-height: 1.2;
text-align: center;
}
.yar-date {
display: block;
margin: 0.45em 0 2.4em;
color: #7a8375;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
text-align: center;
white-space: nowrap;
}
.yar-tags {
display: flex;
flex-wrap: wrap;
gap: 0.55em;
padding: 1em 0 0;
margin: 0;
list-style: none;
}
.yar-tags li {
display: inline-flex;
}
.yar-tags a {
border-radius: 999em;
background: #f3f6f4;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 7%);
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-tags a:hover {
background: #e8eee9;
}
.yar-post-content :global(p:first-child) {
margin-top: 0;
}
.yar-post-content :global(p:last-child) {
margin-bottom: 0;
}
.yar-post-content :global(img) {
display: block;
border-radius: 0.7em;
max-width: 100%;
height: auto;
margin: 1.2em auto;
}
.yar-post-content :global(hr) {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border: 0;
margin: 2em 0;
color: #0003;
font-size: 1.1em;
text-align: center;
}
.yar-post-content :global(hr::before) {
content: "* * *";
}
.yar-post-content :global(pre) {
overflow-x: auto;
border-radius: 0.8em;
background: #f6f8fa;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 8%);
color: #263238;
font-size: 0.92em;
line-height: 1.7;
margin: 1.4em 0;
padding: 1em 1.2em;
}
.yar-post-content :global(pre code) {
background: transparent;
color: inherit;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: inherit;
line-height: inherit;
padding: 0;
}
.yar-post-content :global(:not(pre) > code) {
border-radius: 0.35em;
background: #f3f6f4;
color: #1d3557;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
padding: 0.15em 0.35em;
}
.yar-post-content :global(a) {
color: #1d3557;
font-weight: 600;
text-decoration: underline;
text-decoration-color: rgb(29 53 87 / 24%);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
.yar-post-content :global(a:hover) {
background: #f3f6f4;
text-decoration-color: rgb(29 53 87 / 48%);
}
.yar-post-content :global(p) {
color: #596254;
line-height: 1.8;
}
.yar-footer {
width: 50vw;
margin: auto auto 0;
color: #8a9089;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.8;
text-align: center;
}
.yar-footer p {
margin: 0;
}
@media (max-width: 760px) {
.yar-shell {
padding-top: 24px;
}
.yar-back-row,
.yar-article,
.yar-footer {
width: calc(100% - 28px);
}
.yar-date {
white-space: normal;
}
}
</style>
<script>
const backLink = document.querySelector('[data-history-back]');
backLink?.addEventListener('click', (event) => {
if (window.history.length <= 1) {
return;
}
event.preventDefault();
window.history.back();
});
</script>

View file

@ -0,0 +1,237 @@
---
import SiteNav from '../../SiteNav.astro';
import { site } from '../../../lib/siteConfig';
import type { Post, TagSummary } from '../../../lib/posts';
type Props = {
tag: TagSummary;
posts: Post[];
};
const { tag, posts } = Astro.props;
---
<main class="yar-tag-page">
<div class="yar-shell">
<header class="yar-header">
<div class="yar-header-inner">
<div class="yar-title-box">
<h1 class="yar-title">{site.title}</h1>
</div>
<SiteNav />
</div>
</header>
<section class="yar-tag-posts" aria-label={`Posts tagged ${tag.name}`}>
<div class="yar-current-tag">
<span>{tag.name}</span>
<strong>{tag.count}</strong>
</div>
<div class="yar-post-list">
{posts.map((post) => (
<article class="yar-post-item">
<a href={post.url}>
<h2>{post.title}</h2>
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language)}
</time>
)}
</a>
</article>
))}
</div>
</section>
<footer class="yar-footer">
<p>由 osaet 构建</p>
<p>© 2026 Osaet. All rights reserved.</p>
</footer>
</div>
</main>
<style>
.yar-tag-page {
min-height: 100vh;
background: #ffffff;
color: #1c211b;
}
.yar-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
width: 100%;
padding: 40px 0 5vh;
}
.yar-header {
border-bottom: 1px solid rgba(28, 33, 27, 0.18);
}
.yar-header-inner {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
width: 50vw;
margin: 0 auto;
padding-bottom: 28px;
}
.yar-shell :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
justify-content: flex-end;
margin: 0;
border-radius: 0.75em;
background: #ffffff;
box-shadow: 0 0em 0.2em rgb(29 53 87 / 13%);
padding: 0.5em 1.4em;
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
}
.yar-shell :global(.site-nav a) {
border-radius: 0.65em;
color: inherit;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-shell :global(.site-nav a:hover) {
background: #eef1ed;
text-decoration: none;
}
.yar-title-box {
border-radius: 0.7em;
background: #1d3557;
padding: 1em 1.4em;
}
.yar-title {
margin: 0;
color: #ffffff;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-size: 1em;
letter-spacing: 0.16em;
line-height: 1.15;
}
.yar-tag-posts {
width: 50vw;
margin: 0 auto;
padding: 36px 0 0;
color: #3d3d3f;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
font-size: 16px;
letter-spacing: 1px;
line-height: 1.7em;
}
.yar-current-tag {
display: inline-flex;
gap: 0.7em;
align-items: center;
margin-bottom: 2em;
border-radius: 999em;
background: #f3f6f4;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 7%);
color: #53605a;
padding: 0.7em 1em;
}
.yar-current-tag strong {
color: #1d3557;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
}
.yar-post-list {
display: grid;
gap: 0.9em;
}
.yar-post-item a {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1em;
align-items: center;
border-radius: 0.75em;
color: inherit;
padding: 0.8em 1em;
text-decoration: none;
}
.yar-post-item a:hover {
background: #ffffff;
box-shadow: 0 0em 0.35em rgb(29 53 87 / 13%);
}
.yar-post-item h2 {
margin: 0;
font-size: 1em;
line-height: 1.4;
}
.yar-post-item time {
color: #7a8375;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
white-space: nowrap;
}
.yar-footer {
width: 50vw;
margin: auto auto 0;
color: #8a9089;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.8;
text-align: center;
}
.yar-footer p {
margin: 0;
}
@media (max-width: 760px) {
.yar-shell {
padding-top: 24px;
}
.yar-header-inner,
.yar-tag-posts,
.yar-footer {
width: calc(100% - 28px);
}
.yar-header-inner {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.yar-post-item a {
grid-template-columns: 1fr;
gap: 0.45em;
}
}
</style>

View file

@ -0,0 +1,210 @@
---
import SiteNav from '../../SiteNav.astro';
import { site } from '../../../lib/siteConfig';
import type { TagSummary } from '../../../lib/posts';
type Props = {
tags: TagSummary[];
};
const { tags } = Astro.props;
---
<main class="yar-tags-page">
<div class="yar-shell">
<header class="yar-header">
<div class="yar-header-inner">
<div class="yar-title-box">
<h1 class="yar-title">{site.title}</h1>
</div>
<SiteNav />
</div>
</header>
<section class="yar-tags-section" aria-label="Tags">
{tags.length === 0 ? (
<p class="yar-empty">No tags yet.</p>
) : (
<ul class="yar-tag-index">
{tags.map((tag) => (
<li>
<a href={`/tags/${tag.slug}/`}>
<span>{tag.name}</span>
<strong>{tag.count}</strong>
</a>
</li>
))}
</ul>
)}
</section>
<footer class="yar-footer">
<p>由 osaet 构建</p>
<p>© 2026 Osaet. All rights reserved.</p>
</footer>
</div>
</main>
<style>
.yar-tags-page {
min-height: 100vh;
background: #ffffff;
color: #1c211b;
}
.yar-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
width: 100%;
padding: 40px 0 5vh;
}
.yar-header {
border-bottom: 1px solid rgba(28, 33, 27, 0.18);
}
.yar-header-inner {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
width: 50vw;
margin: 0 auto;
padding-bottom: 28px;
}
.yar-shell :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
justify-content: flex-end;
margin: 0;
border-radius: 0.75em;
background: #ffffff;
box-shadow: 0 0em 0.2em rgb(29 53 87 / 13%);
padding: 0.5em 1.4em;
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
}
.yar-shell :global(.site-nav a) {
border-radius: 0.65em;
color: inherit;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-shell :global(.site-nav a:hover) {
background: #eef1ed;
text-decoration: none;
}
.yar-title-box {
border-radius: 0.7em;
background: #1d3557;
padding: 1em 1.4em;
}
.yar-title {
margin: 0;
color: #ffffff;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-size: 1em;
letter-spacing: 0.16em;
line-height: 1.15;
}
.yar-tags-section {
width: 50vw;
margin: 0 auto;
padding: 36px 0 0;
color: #3d3d3f;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
font-size: 16px;
letter-spacing: 1px;
line-height: 1.7em;
}
.yar-tag-index {
display: flex;
flex-wrap: wrap;
gap: 0.8em;
padding: 0;
margin: 0;
list-style: none;
}
.yar-tag-index a {
display: inline-flex;
gap: 0.7em;
align-items: center;
border-radius: 999em;
background: #f3f6f4;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 7%);
color: #53605a;
padding: 0.7em 1em;
text-decoration: none;
}
.yar-tag-index a:hover {
background: #e8eee9;
}
.yar-tag-index strong {
color: #1d3557;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
font-weight: 700;
}
.yar-empty {
margin: 0;
color: #596254;
text-align: center;
}
.yar-footer {
width: 50vw;
margin: auto auto 0;
color: #8a9089;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.8;
text-align: center;
}
.yar-footer p {
margin: 0;
}
@media (max-width: 760px) {
.yar-shell {
padding-top: 24px;
}
.yar-header-inner,
.yar-tags-section,
.yar-footer {
width: calc(100% - 28px);
}
.yar-header-inner {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
}
</style>

View file

@ -5,15 +5,18 @@ type MarkdownPost = {
summary?: string;
status?: string;
tags?: string[];
created_at?: string;
published_at?: string;
updated_at?: string;
};
Content: any;
};
export type Post = MarkdownPost['frontmatter'] & {
url: string;
date: string;
tags: string[];
Content: any;
};
export type TagSummary = {
@ -27,23 +30,40 @@ export type ArchiveYear = {
posts: Post[];
};
export const POSTS_PER_PAGE = 6;
function isPublicStatus(status?: string) {
return status === 'published' || status === 'archived';
}
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;
const Content = (post as MarkdownPost).Content;
return {
...frontmatter,
Content,
url: `/posts/${frontmatter.slug}/`,
date: frontmatter.published_at ?? frontmatter.updated_at ?? '',
date: frontmatter.created_at ?? frontmatter.published_at ?? frontmatter.updated_at ?? '',
tags: frontmatter.tags ?? []
};
})
.filter((post) => post.status === 'published')
.filter((post) => isPublicStatus(post.status))
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
}
export function getPaginatedPosts(posts: Post[], page: number, perPage = POSTS_PER_PAGE): Post[] {
const start = (page - 1) * perPage;
return posts.slice(start, start + perPage);
}
export function getTotalPages(posts: Post[], perPage = POSTS_PER_PAGE): number {
return Math.max(1, Math.ceil(posts.length / perPage));
}
export function tagSlug(tag: string): string {
return encodeURIComponent(tag.trim().toLowerCase().replace(/\s+/g, '-'));
}

View file

@ -12,6 +12,7 @@ type SiteConfig = {
base_url: string;
language: string;
timezone: string;
theme: string;
};
content: {
posts_dir: string;
@ -29,7 +30,8 @@ const defaults: SiteConfig = {
description: 'Personal blog',
base_url: 'http://localhost:4321',
language: 'zh-CN',
timezone: 'Asia/Shanghai'
timezone: 'Asia/Shanghai',
theme: 'default'
},
content: {
posts_dir: 'content/posts',

View file

@ -1,10 +1,13 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import '../../styles/normalize.css';
import DefaultArchive from '../../components/DefaultArchive.astro';
import YarArchive from '../../components/themes/yar/YarArchive.astro';
import { site } from '../../lib/siteConfig';
import { getArchiveYears, tagSlug } from '../../lib/posts';
import { getArchiveYears } from '../../lib/posts';
const archiveYears = getArchiveYears();
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const ArchiveView = theme === 'yar' ? YarArchive : DefaultArchive;
---
<!doctype html>
@ -16,49 +19,6 @@ const archiveYears = getArchiveYears();
<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>
<ArchiveView archiveYears={archiveYears} />
</body>
</html>

View file

@ -1,10 +1,15 @@
---
import '../styles/global.css';
import SiteNav from '../components/SiteNav.astro';
import '../styles/normalize.css';
import DefaultHome from '../components/DefaultHome.astro';
import YarHome from '../components/themes/yar/YarHome.astro';
import { site } from '../lib/siteConfig';
import { getPublishedPosts, tagSlug } from '../lib/posts';
import { getPaginatedPosts, getPublishedPosts, getTotalPages } from '../lib/posts';
const posts = getPublishedPosts();
const allPosts = getPublishedPosts();
const posts = getPaginatedPosts(allPosts, 1);
const totalPages = getTotalPages(allPosts);
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const Home = theme === 'yar' ? YarHome : DefaultHome;
---
<!doctype html>
@ -16,44 +21,6 @@ const posts = getPublishedPosts();
<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>
<Home posts={posts} currentPage={1} totalPages={totalPages} />
</body>
</html>

View file

@ -0,0 +1,40 @@
---
import '../../styles/normalize.css';
import DefaultHome from '../../components/DefaultHome.astro';
import YarHome from '../../components/themes/yar/YarHome.astro';
import { site } from '../../lib/siteConfig';
import { getPaginatedPosts, getPublishedPosts, getTotalPages } from '../../lib/posts';
export function getStaticPaths() {
const allPosts = getPublishedPosts();
const totalPages = getTotalPages(allPosts);
return Array.from({ length: Math.max(0, totalPages - 1) }, (_, index) => {
const page = index + 2;
return {
params: { page: String(page) },
props: { page }
};
});
}
const { page } = Astro.props;
const allPosts = getPublishedPosts();
const posts = getPaginatedPosts(allPosts, page);
const totalPages = getTotalPages(allPosts);
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const Home = theme === 'yar' ? YarHome : DefaultHome;
---
<!doctype html>
<html lang={site.language}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>第 {page} 页 | {site.title}</title>
<meta name="description" content={site.description} />
</head>
<body>
<Home posts={posts} currentPage={page} totalPages={totalPages} />
</body>
</html>

View file

@ -1,13 +1,13 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import '../../styles/normalize.css';
import DefaultPost from '../../components/DefaultPost.astro';
import YarPost from '../../components/themes/yar/YarPost.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')
.filter((post) => post.frontmatter.status === 'published' || post.frontmatter.status === 'archived')
.map((post) => ({
params: { slug: post.frontmatter.slug },
props: { post }
@ -15,8 +15,9 @@ export function getStaticPaths() {
}
const { post } = Astro.props;
const { Content } = post;
const title = post.frontmatter.title;
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const PostView = theme === 'yar' ? YarPost : DefaultPost;
---
<!doctype html>
@ -28,29 +29,6 @@ const title = post.frontmatter.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>
<PostView post={post} />
</body>
</html>

View file

@ -1,6 +1,7 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import '../../styles/normalize.css';
import DefaultTagPosts from '../../components/DefaultTagPosts.astro';
import YarTagPosts from '../../components/themes/yar/YarTagPosts.astro';
import { site } from '../../lib/siteConfig';
import { getPostsByTag, getTagSummaries } from '../../lib/posts';
@ -13,6 +14,8 @@ export function getStaticPaths() {
const { tag } = Astro.props;
const posts = getPostsByTag(tag.slug);
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const TagPostsView = theme === 'yar' ? YarTagPosts : DefaultTagPosts;
---
<!doctype html>
@ -24,29 +27,6 @@ const posts = getPostsByTag(tag.slug);
<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>
<TagPostsView tag={tag} posts={posts} />
</body>
</html>

View file

@ -1,10 +1,13 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import '../../styles/normalize.css';
import DefaultTags from '../../components/DefaultTags.astro';
import YarTags from '../../components/themes/yar/YarTags.astro';
import { site } from '../../lib/siteConfig';
import { getTagSummaries } from '../../lib/posts';
const tags = getTagSummaries();
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const TagsView = theme === 'yar' ? YarTags : DefaultTags;
---
<!doctype html>
@ -16,28 +19,6 @@ const tags = getTagSummaries();
<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>
<TagsView tags={tags} />
</body>
</html>

180
frontend/site/src/styles/normalize.css vendored Normal file
View file

@ -0,0 +1,180 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
html {
line-height: 1.15;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
}
main {
display: block;
}
h1 {
font-size: 2em;
margin: 0.67em 0;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
pre {
font-family: monospace, monospace;
font-size: 1em;
}
a {
background-color: transparent;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
img {
border-style: none;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
progress {
vertical-align: baseline;
}
textarea {
overflow: auto;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
details {
display: block;
}
summary {
display: list-item;
}
template {
display: none;
}
[hidden] {
display: none;
}