Initial commit

This commit is contained in:
2025-11-28 00:28:49 +08:00
committed by GitHub
commit f9a7c123cc
96 changed files with 17673 additions and 0 deletions

29
src/pages/404.astro Normal file
View File

@@ -0,0 +1,29 @@
---
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import Link from '@/components/Link.astro'
import PageHead from '@/components/PageHead.astro'
import { buttonVariants } from '@/components/ui/button'
import Layout from '@/layouts/Layout.astro'
import { cn } from '@/lib/utils'
---
<Layout class="max-w-3xl">
<PageHead slot="head" title="404" />
<Breadcrumbs items={[{ label: '???', icon: 'lucide:circle-help' }]} />
<section
class="flex flex-col items-center justify-center gap-y-4 text-center"
>
<div class="max-w-md">
<h1 class="mb-4 text-3xl font-medium">404: Page not found</h1>
<p class="prose">Oops! The page you're looking for doesn't exist.</p>
</div>
<Link
href="/"
class={cn(buttonVariants({ variant: 'outline' }), 'flex gap-x-1.5 group')}
>
<span class="transition-transform group-hover:-translate-x-1">&larr;</span
> Go to home page
</Link>
</section>
</Layout>

47
src/pages/about.astro Normal file
View File

@@ -0,0 +1,47 @@
---
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import Link from '@/components/Link.astro'
import PageHead from '@/components/PageHead.astro'
import ProjectCard from '@/components/ProjectCard.astro'
import Layout from '@/layouts/Layout.astro'
import { getAllProjects } from '@/lib/data-utils'
const projects = await getAllProjects()
---
<Layout class="max-w-3xl">
<PageHead slot="head" title="About" />
<Breadcrumbs items={[{ label: 'About', icon: 'lucide:info' }]} />
<section>
<div class="min-w-full">
<div class="prose mb-8">
<p class="mt-0">
astro-erudite is an opinionated, unstyled static blogging template
that prioritizes simplicity and performance, built with <Link
href="https://astro.build"
external
underline>Astro</Link
>, <Link href="https://tailwindcss.com" external underline
>Tailwind</Link
>, and <Link href="https://ui.shadcn.com" external underline
>shadcn/ui</Link
>. It provides a clean foundation for your content while being
extremely easy to customize.
</p>
<p>
To learn more about the philosophy behind this template, check out the
following blog post: <Link
href="/blog/the-state-of-static-blogs"
underline>The State of Static Blogs in 2024</Link
>.
</p>
</div>
<h2 class="mb-4 text-2xl font-medium">Example Projects Listing</h2>
<div class="flex flex-col gap-4">
{projects.map((project) => <ProjectCard project={project} />)}
</div>
</div>
</section>
</Layout>

View File

@@ -0,0 +1,58 @@
---
import AuthorCard from '@/components/AuthorCard.astro'
import BlogCard from '@/components/BlogCard.astro'
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import PageHead from '@/components/PageHead.astro'
import Layout from '@/layouts/Layout.astro'
import { getAllAuthors, getPostsByAuthor } from '@/lib/data-utils'
export async function getStaticPaths() {
const authors = await getAllAuthors()
return authors.map((author) => ({
params: { id: author.id },
props: { author },
}))
}
const { author } = Astro.props
const authorPosts = await getPostsByAuthor(author.id)
---
<Layout class="max-w-3xl">
<PageHead
slot="head"
title={`${author.data.name} (Author)`}
description={author.data.bio || `Profile of ${author.data.name}.`}
noindex
/>
<Breadcrumbs
items={[
{ href: '/authors', label: 'Authors', icon: 'lucide:users' },
{ label: author.data.name, icon: 'lucide:user' },
]}
/>
<section>
<AuthorCard author={author} />
</section>
<section class="flex flex-col gap-y-4">
<h2 class="text-2xl font-medium">Posts by {author.data.name}</h2>
{
authorPosts.length > 0 ? (
<ul class="flex flex-col gap-4">
{authorPosts
.filter((post) => !post.data.draft)
.map((post) => (
<li>
<BlogCard entry={post} />
</li>
))}
</ul>
) : (
<p class="text-muted-foreground">
No posts available from this author.
</p>
)
}
</section>
</Layout>

View File

@@ -0,0 +1,27 @@
---
import AuthorCard from '@/components/AuthorCard.astro'
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import PageHead from '@/components/PageHead.astro'
import Layout from '@/layouts/Layout.astro'
import { getAllAuthors } from '@/lib/data-utils'
const authors = await getAllAuthors()
---
<Layout class="max-w-3xl">
<PageHead slot="head" title="Authors" />
<Breadcrumbs items={[{ label: 'Authors', icon: 'lucide:users' }]} />
{
authors.length > 0 ? (
<ul class="flex flex-col gap-4">
{authors.map((author) => (
<li>
<AuthorCard author={author} />
</li>
))}
</ul>
) : (
<p class="text-muted-foreground text-center">No authors found.</p>
)
}
</Layout>

View File

@@ -0,0 +1,296 @@
---
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import Link from '@/components/Link.astro'
import PostHead from '@/components/PostHead.astro'
import PostNavigation from '@/components/PostNavigation.astro'
import SubpostsHeader from '@/components/SubpostsHeader.astro'
import SubpostsSidebar from '@/components/SubpostsSidebar.astro'
import TOCHeader from '@/components/TOCHeader.astro'
import TOCSidebar from '@/components/TOCSidebar.astro'
import { badgeVariants } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import Layout from '@/layouts/Layout.astro'
import {
getAdjacentPosts,
getAllPostsAndSubposts,
getCombinedReadingTime,
getParentId,
getParentPost,
getPostReadingTime,
getSubpostCount,
getTOCSections,
hasSubposts,
isSubpost,
parseAuthors,
} from '@/lib/data-utils'
import { formatDate } from '@/lib/utils'
import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import { render } from 'astro:content'
export async function getStaticPaths() {
const posts = await getAllPostsAndSubposts()
return posts.map((post) => ({
params: { id: post.id },
props: post,
}))
}
const post = Astro.props
const currentPostId = Astro.params.id
const { Content, headings } = await render(post)
const authors = await parseAuthors(post.data.authors ?? [])
const isCurrentSubpost = isSubpost(currentPostId)
const navigation = await getAdjacentPosts(currentPostId)
const parentPost = isCurrentSubpost ? await getParentPost(currentPostId) : null
const hasChildPosts = await hasSubposts(currentPostId)
const subpostCount = !isCurrentSubpost
? await getSubpostCount(currentPostId)
: 0
const postReadingTime = await getPostReadingTime(currentPostId)
const combinedReadingTime =
hasChildPosts && !isCurrentSubpost
? await getCombinedReadingTime(currentPostId)
: null
const tocSections = await getTOCSections(currentPostId)
---
<Layout>
<PostHead slot="head" post={post} />
{
(hasChildPosts || isCurrentSubpost) && (
<SubpostsHeader
slot="subposts-navigation"
parentId={isCurrentSubpost ? getParentId(currentPostId) : currentPostId}
/>
)
}
{
headings?.length > 0 &&
!(
isCurrentSubpost &&
headings.length === 1 &&
headings[0].text === post.data.title
) && <TOCHeader slot="table-of-contents" headings={headings} />
}
<section
class="grid grid-cols-[minmax(0px,1fr)_min(calc(var(--breakpoint-md)-2rem),100%)_minmax(0px,1fr)] gap-y-6"
>
<div class="col-start-2">
<Breadcrumbs
items={[
{ href: '/blog', label: 'Blog', icon: 'lucide:library-big' },
...(isCurrentSubpost && parentPost
? [
{
href: `/blog/${parentPost.id}`,
label: parentPost.data.title,
icon: 'lucide:book-open',
},
{
href: `/blog/${currentPostId}`,
label: post.data.title,
icon: 'lucide:file-text',
},
]
: [
{
href: `/blog/${currentPostId}`,
label: post.data.title,
icon: 'lucide:book-open-text',
},
]),
]}
/>
</div>
{
post.data.image && (
<Image
src={post.data.image}
alt={post.data.title}
width={1200}
height={630}
class="col-span-full mx-auto w-full max-w-5xl object-cover"
/>
)
}
<section class="col-start-2 flex flex-col gap-y-6 text-center">
<div class="flex flex-col">
<h1
class="mb-2 scroll-mt-31 text-3xl leading-tight font-medium sm:text-4xl"
id="post-title"
>
{post.data.title}
</h1>
<div
class="text-muted-foreground divide-border mb-4 flex flex-col items-center justify-center divide-y text-xs sm:flex-row sm:flex-wrap sm:divide-x sm:divide-y-0 sm:text-sm"
>
{
authors.length > 0 && (
<div class="flex w-full items-center justify-center gap-x-2 py-2 sm:w-fit sm:px-2 sm:py-0 first:sm:pl-0 last:sm:pr-0">
{authors.map((author) => (
<div class="flex items-center gap-x-1.5">
<Image
src={author.avatar}
alt={author.name}
width={20}
height={20}
class="rounded-full"
/>
{author.isRegistered ? (
<Link
href={`/authors/${author.id}`}
underline
class="text-foreground"
>
<span>{author.name}</span>
</Link>
) : (
<span>{author.name}</span>
)}
</div>
))}
</div>
)
}
<div
class="flex w-full items-center justify-center gap-2 py-2 sm:w-fit sm:px-2 sm:py-0 first:sm:pl-0 last:sm:pr-0"
>
<span>{formatDate(post.data.date)}</span>
</div>
<div
class="flex w-full items-center justify-center gap-2 py-2 sm:w-fit sm:px-2 sm:py-0 first:sm:pl-0 last:sm:pr-0"
>
<span>
{postReadingTime}
{
combinedReadingTime &&
combinedReadingTime !== postReadingTime && (
<span class="text-muted-foreground">
{' '}
({combinedReadingTime} total)
</span>
)
}
</span>
</div>
{
subpostCount > 0 && (
<div class="flex w-full items-center justify-center gap-1 py-2 sm:w-fit sm:px-2 sm:py-0 first:sm:pl-0 last:sm:pr-0">
<Icon name="lucide:file-text" class="size-3" />
{subpostCount} subpost{subpostCount === 1 ? '' : 's'}
</div>
)
}
</div>
<div class="flex flex-wrap justify-center gap-2">
{
post.data.tags &&
post.data.tags.length > 0 &&
post.data.tags.map((tag) => (
<a
href={`/tags/${tag}`}
class={badgeVariants({ variant: 'muted' })}
>
<Icon name="lucide:hash" class="size-3" />
{tag}
</a>
))
}
</div>
</div>
<PostNavigation
newerPost={navigation.newer}
olderPost={navigation.older}
parentPost={isCurrentSubpost ? navigation.parent : undefined}
/>
</section>
{
tocSections.length > 0 && (
<TOCSidebar sections={tocSections} currentPostId={currentPostId} />
)
}
<article class="prose col-start-2 max-w-none">
<Content />
</article>
{
(hasChildPosts || isCurrentSubpost) && (
<SubpostsSidebar
parentId={
isCurrentSubpost ? getParentId(currentPostId) : currentPostId
}
className="w-64"
/>
)
}
<PostNavigation
newerPost={navigation.newer}
olderPost={navigation.older}
parentPost={isCurrentSubpost ? navigation.parent : undefined}
/>
</section>
<Button
variant="outline"
size="icon"
className="group fixed right-8 bottom-8 z-50 hidden"
id="scroll-to-top"
title="Scroll to top"
aria-label="Scroll to top"
>
<Icon
name="lucide:arrow-up"
class="mx-auto size-4 transition-all group-hover:-translate-y-0.5"
/>
</Button>
<script>
document.addEventListener('astro:page-load', () => {
const scrollToTopButton = document.getElementById('scroll-to-top')
const footer = document.querySelector('footer')
if (scrollToTopButton && footer) {
scrollToTopButton.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
})
window.addEventListener('scroll', () => {
const footerRect = footer.getBoundingClientRect()
const isFooterVisible = footerRect.top <= window.innerHeight
scrollToTopButton.classList.toggle(
'hidden',
window.scrollY <= 300 || isFooterVisible,
)
})
}
})
</script>
</Layout>
<script>
if (document.querySelector('.katex')) {
if (!document.querySelector('link[href*="katex.min.css"]')) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href =
'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css'
document.head.appendChild(link)
}
}
</script>

View File

@@ -0,0 +1,58 @@
---
import BlogCard from '@/components/BlogCard.astro'
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import PageHead from '@/components/PageHead.astro'
import PaginationComponent from '@/components/ui/pagination'
import { SITE } from '@/consts'
import Layout from '@/layouts/Layout.astro'
import { getAllPosts, groupPostsByYear } from '@/lib/data-utils'
import type { PaginateFunction } from 'astro'
export async function getStaticPaths({
paginate,
}: {
paginate: PaginateFunction
}) {
const allPosts = await getAllPosts()
return paginate(allPosts, { pageSize: SITE.postsPerPage })
}
const { page } = Astro.props
const postsByYear = groupPostsByYear(page.data)
const years = Object.keys(postsByYear).sort((a, b) => parseInt(b) - parseInt(a))
---
<Layout class="max-w-3xl">
<PageHead slot="head" title="Blog" />
<Breadcrumbs
items={[
{ label: 'Blog', href: '/blog', icon: 'lucide:library-big' },
{ label: `Page ${page.currentPage}`, icon: 'lucide:book-copy' },
]}
/>
<div class="flex min-h-[calc(100vh-18rem)] flex-col gap-y-8">
{
years.map((year) => (
<section class="flex flex-col gap-y-4">
<div class="font-medium">{year}</div>
<ul class="flex flex-col gap-4">
{postsByYear[year].map((post) => (
<li>
<BlogCard entry={post} />
</li>
))}
</ul>
</section>
))
}
</div>
<PaginationComponent
currentPage={page.currentPage}
totalPages={page.lastPage}
baseUrl="/blog/"
client:load
/>
</Layout>

85
src/pages/index.astro Normal file
View File

@@ -0,0 +1,85 @@
---
import BlogCard from '@/components/BlogCard.astro'
import Link from '@/components/Link.astro'
import PageHead from '@/components/PageHead.astro'
import { buttonVariants } from '@/components/ui/button'
import { SITE } from '@/consts'
import Layout from '@/layouts/Layout.astro'
import { getRecentPosts } from '@/lib/data-utils'
const blog = await getRecentPosts(SITE.featuredPostCount)
---
<Layout class="max-w-3xl">
<PageHead slot="head" title="Home" />
<section class="rounded-lg border">
<div class="flex flex-col space-y-1.5 p-6">
<h3 class="text-3xl leading-none font-medium">er·u·dite</h3>
<p class="text-muted-foreground text-sm">
/ˈer(y)əˌdīt/ &bull; <span class="font-medium">adjective</span>
</p>
</div>
<div class="p-6 pt-0">
<p class="text-muted-foreground mb-2 text-sm">
astro-erudite is an opinionated, unstyled static blogging template built
with <Link
href="https://astro.build"
class="text-foreground"
external
underline>Astro</Link
>, <Link
href="https://tailwindcss.com"
class="text-foreground"
external
underline>Tailwind</Link
>, and <Link
href="https://ui.shadcn.com"
class="text-foreground"
external
underline>shadcn/ui</Link
>. Extraordinarily loosely based on the <Link
href="https://astro-micro.vercel.app/"
class="text-foreground"
external
underline>Astro Micro</Link
> theme.
</p>
<p class="text-muted-foreground text-sm">
To use this template, check out the <Link
href="https://github.com/jktrn/astro-erudite"
class="text-foreground"
underline
external>GitHub</Link
> repository. To learn more about why this template exists, read this blog
post: <Link
href="/blog/the-state-of-static-blogs"
class="text-foreground"
underline>The State of Static Blogs in 2024</Link
>.
</p>
</div>
</section>
<section class="flex flex-col gap-y-4">
<h2 class="text-2xl font-medium">Latest posts</h2>
<ul class="flex flex-col gap-y-4">
{
blog.map((post) => (
<li>
<BlogCard entry={post} />
</li>
))
}
</ul>
<div class="flex justify-center">
<Link
href="/blog"
class={buttonVariants({ variant: 'ghost' }) + ' group'}
>
See all posts <span
class="ml-1.5 transition-transform group-hover:translate-x-1"
>&rarr;</span
>
</Link>
</div>
</section>
</Layout>

13
src/pages/robots.txt.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro'
const getRobotsTxt = (sitemapURL: URL) => `
User-agent: *
Allow: /
Sitemap: ${sitemapURL.href}
`
export const GET: APIRoute = ({ site }) => {
const sitemapURL = new URL('sitemap-index.xml', site)
return new Response(getRobotsTxt(sitemapURL))
}

25
src/pages/rss.xml.ts Normal file
View File

@@ -0,0 +1,25 @@
import { SITE } from '@/consts'
import rss from '@astrojs/rss'
import type { APIContext } from 'astro'
import { getAllPosts } from '@/lib/data-utils'
export async function GET(context: APIContext) {
try {
const posts = await getAllPosts()
return rss({
title: SITE.title,
description: SITE.description,
site: context.site ?? SITE.href,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.date,
link: `/blog/${post.id}/`,
})),
})
} catch (error) {
console.error('Error generating RSS feed:', error)
return new Response('Error generating RSS feed', { status: 500 })
}
}

View File

@@ -0,0 +1,52 @@
---
import BlogCard from '@/components/BlogCard.astro'
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import PageHead from '@/components/PageHead.astro'
import Layout from '@/layouts/Layout.astro'
import { getAllTags, getPostsByTag } from '@/lib/data-utils'
export async function getStaticPaths() {
const tagMap = await getAllTags()
const uniqueTags = Array.from(tagMap.keys())
return Promise.all(
uniqueTags.map(async (tag) => {
const posts = await getPostsByTag(tag)
return {
params: { id: tag },
props: {
tag,
posts,
},
}
}),
)
}
const { tag, posts } = Astro.props
---
<Layout class="max-w-3xl">
<PageHead
slot="head"
title={`Posts tagged with "${tag}"`}
description={`A collection of posts tagged with ${tag}.`}
noindex
/>
<Breadcrumbs
items={[
{ href: '/tags', label: 'Tags', icon: 'lucide:tags' },
{ label: tag, icon: 'lucide:tag' },
]}
/>
<ul class="flex flex-col gap-y-4">
{
posts.map((post) => (
<li>
<BlogCard entry={post} />
</li>
))
}
</ul>
</Layout>

View File

@@ -0,0 +1,33 @@
---
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import Link from '@/components/Link.astro'
import PageHead from '@/components/PageHead.astro'
import { badgeVariants } from '@/components/ui/badge'
import Layout from '@/layouts/Layout.astro'
import { getSortedTags } from '@/lib/data-utils'
import { Icon } from 'astro-icon/components'
const sortedTags = await getSortedTags()
---
<Layout class="max-w-3xl">
<PageHead slot="head" title="Tags" />
<Breadcrumbs items={[{ label: 'Tags', icon: 'lucide:tags' }]} />
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-2">
{
sortedTags.map(({ tag, count }) => (
<Link
href={`/tags/${tag}`}
class={badgeVariants({ variant: 'muted' })}
>
<Icon name="lucide:hash" class="size-3" />
{tag}
<span class="text-muted-foreground ml-1.5">({count})</span>
</Link>
))
}
</div>
</div>
</Layout>