Commit 7ee18fb
Changed files (8)
src
components
layouts
pages
posts
types
utils
src/components/PostInfo.astro
@@ -0,0 +1,83 @@
+---
+import { articleConfig } from '@/config';
+import I18nKey from '@i18n/I18nKey';
+import { i18n } from '@i18n/translation';
+import { countWords } from '@utils/content-utils';
+import MetaIcon from './widgets/MetaIcon.astro';
+
+interface Props {
+ title: string;
+ publishedAt: Date;
+ category: string;
+ tags: string[];
+ wordCount: ReturnType<typeof countWords>;
+ class?: string;
+}
+
+const { title, publishedAt, category, tags, wordCount, class: className } = Astro.props;
+
+const readTime =
+ Math.ceil(wordCount.cjk / articleConfig.readingTime.wordsPerMinute.cjk) +
+ Math.ceil(wordCount.nonCjk / articleConfig.readingTime.wordsPerMinute.nonCjk);
+
+const metas: ({ icon: string; text: string; link?: string } | undefined)[] = [
+ {
+ icon: 'material-symbols:calendar-clock-outline-rounded',
+ text: publishedAt.toLocaleDateString(),
+ },
+ articleConfig.wordCount
+ ? {
+ icon: 'material-symbols:docs-rounded',
+ text: `${wordCount.total} ${wordCount.total === 1 ? i18n(I18nKey.wordCount) : i18n(I18nKey.wordsCount)}`,
+ }
+ : undefined,
+ articleConfig.readingTime
+ ? {
+ icon: 'material-symbols:nest-clock-farsight-analog-rounded',
+ text: `${readTime} ${readTime === 1 ? i18n(I18nKey.minuteCount) : i18n(I18nKey.minutesCount)}`,
+ }
+ : undefined,
+ category
+ ? {
+ icon: 'material-symbols:category-outline-rounded',
+ text: category,
+ link: `/archives/categories/${category.replaceAll(/[\\/]/g, '-')}/1/`,
+ }
+ : undefined,
+ ...tags.map((tag) => {
+ return {
+ icon: 'material-symbols:tag-rounded',
+ text: tag,
+ link: `/archives/tags/${tag.replaceAll(/[\\/]/g, '-')}/1/`,
+ };
+ }),
+];
+---
+
+<div id="post-info" class:list={['flex flex-col', className]}>
+ <h1 class="text-3xl font-bold">{title}</h1>
+ <div id="post-meta" class="mt-4 flex flex-wrap gap-3">
+ {
+ metas.map((meta) => {
+ return (
+ meta && (
+ <div class="flex items-center">
+ <MetaIcon name={meta.icon} />
+ {meta.link ? (
+ <a
+ href={meta.link}
+ class="theme-text-second text-sm font-medium duration-100 hover:brightness-125"
+ title={meta.text}
+ >
+ {meta.text}
+ </a>
+ ) : (
+ <span class="theme-text-second text-sm font-medium">{meta.text}</span>
+ )}
+ </div>
+ )
+ );
+ })
+ }
+ </div>
+</div>
src/layouts/GridLayout.astro
@@ -10,13 +10,18 @@ const { title, description, lang } = Astro.props;
---
<MainLayout title={title} description={description} lang={lang}>
- <div id="main-content" class="my-4 w-full">
- <slot />
- </div>
- <div id="aside-content" class="my-4 flex w-96 flex-col gap-4 max-xl:hidden">
- <slot name="aside-fixed" slot="aside-fixed" />
- <div class="sticky top-20 flex flex-col gap-4">
- <slot name="aside-sticky" slot="aside-sticky" />
+ <div class="mx-auto flex max-w-screen-xl flex-col gap-4">
+ <slot name="header-content" />
+ <div class="flex gap-4">
+ <div id="main-content" class="my-4 w-full">
+ <slot />
+ </div>
+ <div id="aside-content" class="my-4 flex w-96 flex-col gap-4 max-xl:hidden">
+ <slot name="aside-fixed" />
+ <div class="sticky top-20 flex flex-col gap-4">
+ <slot name="aside-sticky" />
+ </div>
+ </div>
</div>
</div>
</MainLayout>
src/layouts/MainLayout.astro
@@ -18,11 +18,10 @@ const { title, description, lang } = Astro.props;
<Sidebar />
<SideToolBar />
<Navbar />
+ <slot name="header" />
<div id="body-wrap" class="w-full items-center md:px-4">
<!-- Main content -->
- <div id="content-wrapper" class="mx-auto flex max-w-screen-xl gap-4">
- <slot />
- </div>
+ <slot />
</div>
<PageFooter />
</GlobalLayout>
src/pages/posts/[article].astro
@@ -1,9 +1,12 @@
---
+import { articleConfig } from '@/config';
import '@/styles/article.scss';
import License from '@components/License.astro';
+import PostInfo from '@components/PostInfo.astro';
import ProfileCard from '@components/widgets/ProfileCard.astro';
import TOC from '@components/widgets/TOC.astro';
import GridLayout from '@layouts/GridLayout.astro';
+import { countWords } from '@utils/content-utils';
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
@@ -16,9 +19,21 @@ export async function getStaticPaths() {
const { article } = Astro.props;
const { Content, headings } = await render(article);
+
+const wordCount = countWords(article.body || '');
---
<GridLayout>
+ <Fragment slot="header-content">
+ <PostInfo
+ title={article.data.title}
+ publishedAt={article.data.published}
+ category={article.data.category}
+ tags={article.data.tags}
+ wordCount={wordCount}
+ class="mx-2 mt-4"
+ />
+ </Fragment>
<div class="theme-card-bg theme-border rounded-xl border-2 px-6 py-4">
<article>
<Content />
@@ -29,6 +44,6 @@ const { Content, headings } = await render(article);
<ProfileCard />
</Fragment>
<Fragment slot="aside-sticky">
- <TOC headings={headings} />
+ {articleConfig.toc && <TOC headings={headings} />}
</Fragment>
</GridLayout>
src/types/config.ts
@@ -4,7 +4,6 @@ export type SiteConfig = {
title: string;
subtitle: string;
lang: string;
- copyrightYear: number;
favicon: (string | { src: string; theme?: 'light' | 'dark' })[];
postsPerPage: number;
};
@@ -48,3 +47,20 @@ export type LicenseConfig = {
name: string;
url: string;
};
+
+export type FooterConfig = {
+ copyrightYear: number;
+ rightItems: (string | { text: string; link?: string; class?: string })[][];
+};
+
+export type ArticleConfig = {
+ toc: boolean;
+ wordCount: boolean;
+ readingTime: {
+ enable: boolean;
+ wordsPerMinute: {
+ cjk: number;
+ nonCjk: number;
+ };
+ };
+};
src/utils/content-utils.ts
@@ -65,3 +65,19 @@ export async function getTimeArchives() {
}));
return timeReducedPosts;
}
+
+export function countWords(text: string): { cjk: number; nonCjk: number; total: number } {
+ const cjkRegex =
+ /[\u4E00-\u9FFF\u3400-\u4DBF\u20000-\u2A6DF\u2A700-\u2B73F\u2B740-\u2B81F\u2B820-\u2CEAF\uF900-\uFAFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/g;
+ const cjkCount = (text.match(cjkRegex) || []).length;
+ const nonCjkText = text.replace(cjkRegex, '');
+ const wordCount = nonCjkText
+ .trim()
+ .split(/\s+/)
+ .filter((word) => word.length > 0).length;
+ return {
+ cjk: cjkCount,
+ nonCjk: wordCount,
+ total: cjkCount + wordCount,
+ };
+}
src/config.ts
@@ -1,4 +1,6 @@
import type {
+ ArticleConfig,
+ FooterConfig,
LicenseConfig,
NavbarConfig,
ProfileConfig,
@@ -12,7 +14,6 @@ export const siteConfig: SiteConfig = {
subtitle: '',
lang: 'zh_CN', // "en" | "zh_CN" | "zh_TW"
favicon: [''],
- copyrightYear: 2025,
postsPerPage: 10,
};
@@ -65,3 +66,35 @@ export const licenseConfig: LicenseConfig = {
name: 'CC BY-NC-SA 4.0',
url: 'https://creativecommons.org/licenses/by-nc-sa/4.0/',
};
+
+export const footerConfig: FooterConfig = {
+ copyrightYear: 2025,
+ rightItems: [
+ [
+ {
+ text: 'Astro',
+ link: 'https://astro.build/',
+ class: 'font-bold text-sm',
+ },
+ ],
+ [
+ {
+ text: 'Astral Halo',
+ link: 'https://github.com/HPCesia/astral-halo/',
+ class: 'font-bold text-sm',
+ },
+ ],
+ ],
+};
+
+export const articleConfig: ArticleConfig = {
+ toc: true,
+ wordCount: true,
+ readingTime: {
+ enable: true,
+ wordsPerMinute: {
+ cjk: 300,
+ nonCjk: 160,
+ },
+ },
+};