Compare commits

...

11 Commits

Author SHA1 Message Date
6c525bcf98 解决构建问题
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-07 13:34:10 +08:00
7a3059f486 添加 RSS 侧渲染和抓取缓存
Some checks failed
continuous-integration/drone/push Build is failing
2026-04-07 13:19:01 +08:00
1b8bd19370 添加博客渲染缓存 2026-04-07 12:57:47 +08:00
ca1cf10aa8 添加错误重试机制
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-07 12:19:13 +08:00
880afe34ab 微调排版
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-07 03:35:00 +08:00
c3973e779a 添加标题上边距 2026-04-07 03:32:17 +08:00
d605131bda 添加格式化相关
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-07 03:27:22 +08:00
2a71d714e8 添加 code 元素样式
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-05 21:24:50 +08:00
a47a537a64 修复链接
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-05 21:21:48 +08:00
e36492a692 优化排版
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-05 21:17:40 +08:00
9c019351a3 添加博客头图 2026-04-05 20:59:31 +08:00
19 changed files with 1173 additions and 159 deletions

1
.husky/pre-commit Normal file
View File

@ -0,0 +1 @@
npx lint-staged

View File

@ -8,4 +8,3 @@
npm i npm i
npm run dev npm run dev
``` ```

View File

@ -12,6 +12,7 @@ const ICON2_URL =
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
output: 'server',
adapter: node({ adapter: node({
mode: 'standalone', mode: 'standalone',
}), }),
@ -21,4 +22,11 @@ export default defineConfig({
redirects: { redirects: {
'/assets/icon-qq-BThBBmjV.jpg': ICON2_URL, '/assets/icon-qq-BThBBmjV.jpg': ICON2_URL,
}, },
vite: {
build: {
rollupOptions: {
external: ['node:crypto', 'buffer', 'keyv'],
},
},
},
}) })

43
eslint.config.mjs Normal file
View File

@ -0,0 +1,43 @@
import eslintPluginAstro from 'eslint-plugin-astro'
import tseslint from '@typescript-eslint/eslint-plugin'
import tsparser from '@typescript-eslint/parser'
import svelteParser from 'svelte-eslint-parser'
export default [
{
ignores: ['dist/**', 'node_modules/**', '.astro/**'],
plugins: {
astro: eslintPluginAstro,
'@typescript-eslint': tseslint,
},
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
rules: {
...eslintPluginAstro.configs.recommended.rules,
...tseslint.configs.recommended.rules,
'no-console': 'warn',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
},
},
{
files: ['**/*.svelte'],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: tsparser,
ecmaVersion: 'latest',
sourceType: 'module',
},
},
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
]

761
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,20 @@
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "astro": "astro",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"prepare": "husky"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,svelte,vue}": [
"eslint --fix",
"prettier --write"
],
"*.{astro,css,md,mdx,json}": [
"prettier --write"
]
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^10.0.4", "@astrojs/node": "^10.0.4",
@ -29,6 +42,7 @@
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"axios": "^1.13.6", "axios": "^1.13.6",
"katex": "^0.16.43", "katex": "^0.16.43",
"keyv": "^5.6.0",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@ -39,10 +53,15 @@
}, },
"devDependencies": { "devDependencies": {
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.57.2", "@typescript-eslint/parser": "^8.57.2",
"eslint": "^10.1.0", "eslint": "^10.1.0",
"eslint-plugin-astro": "^1.6.0", "eslint-plugin-astro": "^1.6.0",
"eslint-plugin-vue": "^10.8.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-astro": "^0.14.1" "prettier-plugin-astro": "^0.14.1",
"svelte-eslint-parser": "^1.6.0"
} }
} }

View File

@ -17,6 +17,12 @@ const config = {
parser: 'astro', parser: 'astro',
}, },
}, },
{
files: '*.css',
options: {
tabWidth: 4,
},
},
], ],
} }

View File

@ -1,6 +1,7 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Bold.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Bold.woff2')
format('woff2');
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -8,7 +9,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-BoldItalic.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-BoldItalic.woff2')
format('woff2');
font-weight: 700; font-weight: 700;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
@ -16,7 +18,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-ExtraBold.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-ExtraBold.woff2')
format('woff2');
font-weight: 800; font-weight: 800;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -24,7 +27,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-ExtraBoldItalic.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-ExtraBoldItalic.woff2')
format('woff2');
font-weight: 800; font-weight: 800;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
@ -32,7 +36,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-ExtraLight.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-ExtraLight.woff2')
format('woff2');
font-weight: 200; font-weight: 200;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -40,7 +45,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-ExtraLightItalic.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-ExtraLightItalic.woff2')
format('woff2');
font-weight: 200; font-weight: 200;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
@ -48,7 +54,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Italic.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Italic.woff2')
format('woff2');
font-weight: 400; font-weight: 400;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
@ -56,7 +63,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Light.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Light.woff2')
format('woff2');
font-weight: 300; font-weight: 300;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -64,7 +72,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-LightItalic.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-LightItalic.woff2')
format('woff2');
font-weight: 300; font-weight: 300;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
@ -72,7 +81,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Medium.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Medium.woff2')
format('woff2');
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -80,7 +90,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-MediumItalic.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-MediumItalic.woff2')
format('woff2');
font-weight: 500; font-weight: 500;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
@ -88,7 +99,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Regular.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Regular.woff2')
format('woff2');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -96,7 +108,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-SemiBold.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-SemiBold.woff2')
format('woff2');
font-weight: 600; font-weight: 600;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -104,7 +117,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-SemiBoldItalic.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-SemiBoldItalic.woff2')
format('woff2');
font-weight: 600; font-weight: 600;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
@ -112,7 +126,8 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Thin.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-Thin.woff2')
format('woff2');
font-weight: 100; font-weight: 100;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -120,19 +135,23 @@
@font-face { @font-face {
font-family: 'Maple Mono'; font-family: 'Maple Mono';
src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-ThinItalic.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/MapleMonoNormal-NF-CN-ThinItalic.woff2')
format('woff2');
font-weight: 100; font-weight: 100;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "ZLabsRoundPix 16px M CN"; font-family: 'ZLabsRoundPix 16px M CN';
src: url('https://cdn.passthem.top/fonts/ZLabsRoundPix_16px_M_CN.ttf.woff2') format('woff2'); src: url('https://cdn.passthem.top/fonts/ZLabsRoundPix_16px_M_CN.ttf.woff2')
format('woff2');
font-display: swap; font-display: swap;
} }
:root { :root {
--font-mono: 'Maple Mono NF CN', 'Maple Mono', monospace, var(--font-sans); --font-mono: 'Maple Mono NF CN', 'Maple Mono', monospace, var(--font-sans);
--font-sans: 'HarmonyOS Sans SC', 'Source Han Sans SC', 'Noto Sans CJK SC', sans-serif; --font-sans:
'HarmonyOS Sans SC', 'Source Han Sans SC', 'Noto Sans CJK SC',
sans-serif;
} }

View File

@ -1,8 +1,16 @@
.prose { .prose {
line-height: 1.6em; & p,
& h1,
& h2,
& h3,
& h4,
& h5,
& h6 {
line-height: 1.6em;
}
& p { & p {
margin-block: .6em; margin-block: 0.6em;
} }
& h1, & h1,
@ -44,16 +52,16 @@
background-color: var(--color-bg-1); background-color: var(--color-bg-1);
color: var(--color-fg-1); color: var(--color-fg-1);
border-radius: .5rem; border-radius: 0.5rem;
padding-block: .5rem; padding-block: 0.5rem;
padding-inline: 1.25rem; padding-inline: 1.25rem;
margin-block: 1.25rem; margin-block: 1.25rem;
border-inline-start: solid .25rem; border-inline-start: solid 0.25rem;
} }
& pre.shiki { & pre.shiki {
border-radius: .5rem; border-radius: 0.5rem;
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1.25rem; padding-inline: 1.25rem;
margin-block: 1.25rem; margin-block: 1.25rem;
@ -69,7 +77,18 @@
} }
& img { & img {
border-radius: .5rem; border-radius: 0.5rem;
margin-block: 1.25rem; margin-block: 1.25rem;
} }
& p > code {
background-color: var(--color-bg-1);
padding: 0.25rem;
border-radius: 0.25rem;
}
& pre,
& code {
font-family: var(--font-mono);
}
} }

View File

@ -47,7 +47,7 @@ body {
/* == 配置默认字体 == */ /* == 配置默认字体 == */
code { code {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: .9rem; font-size: 0.9rem;
} }
/* == 支持 Shiki 的高亮 == */ /* == 支持 Shiki 的高亮 == */

View File

@ -16,8 +16,8 @@ interface Props {
const { link, title, featureImage, author, createdAt, updatedAt } = Astro.props const { link, title, featureImage, author, createdAt, updatedAt } = Astro.props
const formatDate = (date: Date) => `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日` const formatDate = (date: Date) =>
`${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日`
--- ---
<a class="card" href={link}> <a class="card" href={link}>
@ -35,25 +35,38 @@ const formatDate = (date: Date) => `${date.getFullYear()} 年 ${date.getMonth()
} }
<div class="info"> <div class="info">
<h1 class="title">{title}</h1> <h1 class="title">{title}</h1>
{author && <div class="author"> {
<Image author && (
class="image" <div class="author">
src={author.avatar ?? ""} <Image
alt=`用户 ${author.name} 的头像` class="image"
width={24} src={author.avatar ?? ''}
height={24} alt={`用户 ${author.name} 的头像`}
densities={[1.5, 2]} width={24}
/> height={24}
<span class="content">{author.name}</span> densities={[1.5, 2]}
</div>} />
{createdAt && <div class="iconinfo"> <span class="content">{author.name}</span>
<Icon class="icon" name="material-symbols:calendar-add-on-rounded" /> </div>
<span class="content">{formatDate(createdAt)}</span> )
</div>} }
{updatedAt && (!createdAt || formatDate(createdAt) != formatDate(updatedAt)) && <div class="iconinfo"> {
<Icon class="icon" name="material-symbols:calendar-check-rounded" /> createdAt && (
<span class="content">{formatDate(updatedAt)}</span> <div class="iconinfo">
</div>} <Icon class="icon" name="material-symbols:calendar-add-on-rounded" />
<span class="content">{formatDate(createdAt)}</span>
</div>
)
}
{
updatedAt &&
(!createdAt || formatDate(createdAt) != formatDate(updatedAt)) && (
<div class="iconinfo">
<Icon class="icon" name="material-symbols:calendar-check-rounded" />
<span class="content">{formatDate(updatedAt)}</span>
</div>
)
}
</div> </div>
</a> </a>
@ -105,41 +118,46 @@ const formatDate = (date: Date) => `${date.getFullYear()} 年 ${date.getMonth()
& > .info { & > .info {
& > h1 { & > h1 {
margin-inline: 0.75rem; margin-inline: 0.5rem;
font-size: larger; font-size: larger;
font-weight: 600; font-weight: 600;
margin-block-start: 0.25rem; margin-block-start: 0.25rem;
margin-block-end: 0.75rem; margin-block-end: 0.75rem;
@media (width < 768px) {
margin-inline: 0.75rem;
}
} }
&>.author, &>.iconinfo { & > .author,
& > .iconinfo {
margin-inline: 0.5rem; margin-inline: 0.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: .5rem; gap: 0.5rem;
margin-block: 0.1rem; margin-block: 0.1rem;
&>.image { & > .image {
aspect-ratio: 1; aspect-ratio: 1;
object-fit: cover; object-fit: cover;
width: var(--icon-size); width: var(--icon-size);
border-radius: 50%; border-radius: 50%;
} }
&>.icon { & > .icon {
width: var(--icon-size); width: var(--icon-size);
height: var(--icon-size); height: var(--icon-size);
padding: 2px; padding: 2px;
opacity: .5; opacity: 0.5;
} }
&>.content { & > .content {
opacity: .5; opacity: 0.5;
font-size: 0.9rem; font-size: 0.9rem;
} }
} }
&>.author { & > .author {
margin-block: 0.25rem; margin-block: 0.25rem;
} }
} }

View File

@ -24,6 +24,10 @@ export type ListBlogItemType = {
} }
} }
export type BlogContentType = ListBlogItemType & {
content: string
}
export const listBlogs = async ({ export const listBlogs = async ({
page = 1, page = 1,
limit = 20, limit = 20,
@ -58,17 +62,30 @@ export const listBlogs = async ({
export const getBlog: ( export const getBlog: (
blog_id: number, blog_id: number,
) => Promise<null | { title: string; content: string }> = async ( ) => Promise<null | BlogContentType> = async (blog_id: number) => {
blog_id: number,
) => {
const resp = await legacyClient.get(`/v1/blog/${blog_id}`, { const resp = await legacyClient.get(`/v1/blog/${blog_id}`, {
validateStatus: (status) => status == 200 || status == 404, validateStatus: (status) => status == 200 || status == 404,
}) })
if (resp.status == 404) { if (resp.status == 404) {
return null return null
} }
return resp.data as { let _blog = resp.data
title: string
content: string if (_blog.featured_image) {
_blog = {
..._blog,
featured_image: {
image_url:
'https://legacy.passthem.top' + _blog.featured_image.image_url,
},
}
} }
_blog.author.avatar = {
image_url: 'https://legacy.passthem.top' + _blog.author.avatar.image_url,
}
_blog.created_at = new Date(_blog.created_at)
_blog.updated_at = new Date(_blog.updated_at)
return _blog
} }

16
src/lib/markdoc-cache.ts Normal file
View File

@ -0,0 +1,16 @@
import type { RenderableTreeNode } from '@markdoc/markdoc'
import KeyV from 'keyv'
import { createHash } from 'node:crypto'
const markdocVersion = '1'
export const markdocCache = new KeyV<RenderableTreeNode>({
namespace: `markdoc-cache-${markdocVersion}`,
ttl: 1000 * 60 * 60 * 24 * 7,
})
export const generateCacheKey = (content: string) => {
return createHash('sha256')
.update(content)
.update(markdocVersion)
.digest('hex')
}

View File

@ -12,6 +12,15 @@ export const markdocConfig: Config = {
highlightedHtml: { type: String }, highlightedHtml: { type: String },
}, },
transform(node, config) { transform(node, config) {
/*
* 注意力!!!!!!!!!!!!!!
* 注意!!!!!!!!!!!!
*
* 如果改了这里的逻辑,记得更改 `markdoc-cache.ts` 里的版本号
* 以让对应的缓存能够被更新
*
**/
const attributes = node.transformAttributes(config) const attributes = node.transformAttributes(config)
const highlightedHtml = shikiRender( const highlightedHtml = shikiRender(
@ -29,8 +38,28 @@ export const markdocConfig: Config = {
}, },
} }
export const toMarkdocTree = async (content: string) => { const _toMarkdocTree = async (content: string) => {
const ast = Markdoc.parse(content) const ast = Markdoc.parse(content)
await ensureShikiEngine() await ensureShikiEngine()
return Markdoc.transform(ast, markdocConfig) const tree = Markdoc.transform(ast, markdocConfig)
return tree
}
export const toMarkdocTree = async (content: string) => {
if (!import.meta.env.SSR) {
return await _toMarkdocTree(content)
}
const cacheLib = await import('./markdoc-cache.ts')
const key = cacheLib.generateCacheKey(content)
const cached = await cacheLib.markdocCache.get(key)
if (cached) {
return cached
}
const tree = await _toMarkdocTree(content)
cacheLib.markdocCache.set(key, tree)
return tree
} }

View File

@ -1,6 +1,6 @@
--- ---
import FullLayoutV1 from '../layout/FullLayoutV1.astro' import FullLayoutV1 from '../layout/FullLayoutV1.astro'
import { listBlogs } from '../lib/apis/legacy/blog' import { listBlogs, type ListBlogItemType } from '../lib/apis/legacy/blog'
import BlogCard from '../components/BlogCard.astro' import BlogCard from '../components/BlogCard.astro'
import BlogHeaderImage from '../assets/blogs-header.webp' import BlogHeaderImage from '../assets/blogs-header.webp'
import { Image } from 'astro:assets' import { Image } from 'astro:assets'
@ -10,7 +10,13 @@ export const prerender = false
const _page = parseInt(Astro.url.searchParams.get('page') || '1') const _page = parseInt(Astro.url.searchParams.get('page') || '1')
const page = isNaN(_page) ? 1 : Math.max(1, _page) const page = isNaN(_page) ? 1 : Math.max(1, _page)
const blogs = await listBlogs({ page, limit: 100 }) let blogs: ListBlogItemType[] = []
try {
blogs = await listBlogs({ page, limit: 100 })
} catch {
return Astro.redirect('/500')
}
--- ---
<FullLayoutV1 withGap title="博客列表 - 小帕的小窝"> <FullLayoutV1 withGap title="博客列表 - 小帕的小窝">

View File

@ -3,6 +3,8 @@ import { getBlog } from '../../lib/apis/legacy/blog'
import FullLayoutV1 from '../../layout/FullLayoutV1.astro' import FullLayoutV1 from '../../layout/FullLayoutV1.astro'
import { MarkdocTreeRender } from '../../components/MarkdocRenderer.tsx' import { MarkdocTreeRender } from '../../components/MarkdocRenderer.tsx'
import { toMarkdocTree } from '../../lib/markdoc' import { toMarkdocTree } from '../../lib/markdoc'
import { Image } from 'astro:assets'
import { Icon } from 'astro-icon/components'
import Artalk from '../../components/Artalk.svelte' import Artalk from '../../components/Artalk.svelte'
@ -15,21 +17,68 @@ if (isNaN(blog_id_num) || blog_id_num < 0) {
return Astro.redirect('/404') return Astro.redirect('/404')
} }
const blogData = await getBlog(blog_id_num) let blogData = null
try {
blogData = await getBlog(blog_id_num)
} catch (e) {
return Astro.redirect('/500')
}
if (blogData === null) { if (blogData === null) {
return Astro.redirect('/404') return Astro.redirect('/404')
} }
const tree = await toMarkdocTree(blogData.content) const tree = await toMarkdocTree(blogData.content)
const formatDate = (date: Date) =>
`${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日`
--- ---
<FullLayoutV1 title={blogData.title} withGap> <FullLayoutV1 title={blogData.title} withGap>
{
blogData.featured_image && (
<div class="featured-image">
<Image
class="image"
src={blogData.featured_image.image_url}
width={960}
height={540}
alt="博客的头图"
/>
</div>
)
}
<main> <main>
<h1>{blogData.title}</h1> <h1>{blogData.title}</h1>
{
blogData.author && (
<div class="blog-info">
<a
class="author"
href={`https://legacy.passthem.top/user/${blogData.author.username}`}
>
<div class="avatar">
<Image
class="image"
src={blogData.author.avatar.image_url}
width={32}
height={32}
alt={`用户 ${blogData.author.nickname} 的头像`}
/>
</div>
<div class="username">{blogData.author.nickname}</div>
</a>
<span class="split">・</span>
<span>{formatDate(blogData.updated_at)} 更新</span>
</div>
)
}
<article> <article>
<MarkdocTreeRender tree={tree} client:load /> <MarkdocTreeRender tree={tree} client:load />
</article> </article>
<h1>评论区</h1> <h1 class="comment-title">
<Icon name="material-symbols:chat-rounded" /> 评论区
</h1>
<Artalk client:idle /> <Artalk client:idle />
</main> </main>
</FullLayoutV1> </FullLayoutV1>
@ -47,15 +96,85 @@ const tree = await toMarkdocTree(blogData.content)
} }
} }
.featured-image {
margin-inline: auto;
width: 100%;
max-width: 960px;
aspect-ratio: 16 / 9;
margin-block-end: 2rem;
position: relative;
& > .image {
object-fit: cover;
height: 100%;
width: 100%;
}
&::after {
content: '';
display: block;
position: absolute;
left: 0;
bottom: 0;
height: 50px;
width: 100%;
background: linear-gradient(to bottom, transparent, #00000044);
}
@media (width < 768px) and (width >= 540px) {
aspect-ratio: 2.5 / 1;
}
@media (width >= 768px) {
aspect-ratio: 4 / 1;
margin-block-end: 3rem;
}
}
.blog-info {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1rem;
color: var(--color-fg-1);
& > .author {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
& > .avatar {
& > .image {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 16px;
}
}
}
}
main { main {
max-inline-size: 840px; max-inline-size: 840px;
margin-inline: auto; margin-inline: auto;
padding-block-end: 2rem; padding-block-end: 4rem;
padding-inline: 20px;
& > h1 { & > h1 {
font-size: 2.4rem; font-size: 2rem;
font-weight: 700; font-weight: 900;
margin-block: 1.2rem; margin-block-end: 1.2rem;
margin-block-start: 2rem;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
&.comment-title {
margin-block-start: 3rem;
}
} }
} }
</style> </style>

View File

@ -96,7 +96,12 @@ try {
<Icon name="mdi:email" /> <Icon name="mdi:email" />
</div> </div>
<div class="contact-container"> <div class="contact-container">
<h1><div class="text">联系我<div class="animation-before"></div></div><div class="animation"></div></h1> <h1>
<div class="text">联系我<div class="animation-before"></div></div><div
class="animation"
>
</div>
</h1>
<table> <table>
<tbody> <tbody>
<tr> <tr>
@ -188,22 +193,26 @@ try {
[ [
'https://legacy.passthem.top/assets/ydt-DIeb2Djx.png', 'https://legacy.passthem.top/assets/ydt-DIeb2Djx.png',
'https://qm.qq.com/q/nDnHUy9KQo', 'https://qm.qq.com/q/nDnHUy9KQo',
'有顶天变电站 (QQ)' '有顶天变电站 (QQ)',
], ],
[ [
'https://legacy.passthem.top/assets/lfxdxy-BogfTZvz.png', 'https://legacy.passthem.top/assets/lfxdxy-BogfTZvz.png',
'https://qm.qq.com/q/QOpCVZcvyS', 'https://qm.qq.com/q/QOpCVZcvyS',
'六方相的新月 (QQ)' '六方相的新月 (QQ)',
], ],
].map(b => <a href={b[1]} class="friend"> ].map((b) => (
<div class="avatar"> <a href={b[1]} class="friend">
<Image src={b[0]} width={60} height={60} alt=`网站「${b[2]}」的图标` /> <div class="avatar">
</div> <Image
<div class="description"> src={b[0]}
{b[2]} width={60}
</div> height={60}
</a> alt={`网站「${b[2]}」的图标`}
) />
</div>
<div class="description">{b[2]}</div>
</a>
))
} }
</div> </div>
</section> </section>
@ -567,7 +576,6 @@ try {
& > .text { & > .text {
background-color: var(--color-fg-0); background-color: var(--color-fg-0);
position: relative; position: relative;
} }
& .animation-before { & .animation-before {
@ -603,8 +611,7 @@ try {
background-repeat: repeat; background-repeat: repeat;
background-size: var(--bg-width) 100%; background-size: var(--bg-width) 100%;
animation: bg-contact-roll infinite linear 2s; animation: bg-contact-roll infinite linear 2s;
transform: skewX(var(--skew-angle)) transform: skewX(var(--skew-angle)) translateX(calc(var(--unit) - 1px));
translateX(calc(var(--unit) - 1px));
} }
} }
@ -678,7 +685,7 @@ try {
} }
/* 友链页 */ /* 友链页 */
.slide:has(>.friends) { .slide:has(> .friends) {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
@ -695,7 +702,7 @@ try {
overflow-y: auto; overflow-y: auto;
margin-inline: 1rem; margin-inline: 1rem;
/* background-color: rgb(from var(--color-bg-0) r g b / .3); */ /* background-color: rgb(from var(--color-bg-0) r g b / .3); */
background-color: rgb(from var(--color-bg-2) r g b / .5); background-color: rgb(from var(--color-bg-2) r g b / 0.5);
border-radius: 2rem; border-radius: 2rem;
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
@ -751,7 +758,8 @@ try {
outline-offset: 3px; outline-offset: 3px;
} }
&:hover, &:focus { &:hover,
&:focus {
transform: scale(1.1); transform: scale(1.1);
} }

View File

@ -8,9 +8,45 @@ import {
import { ensureShikiEngine } from '../lib/markdown' import { ensureShikiEngine } from '../lib/markdown'
// import { toMarkdocTree } from '../lib/markdoc' // import { toMarkdocTree } from '../lib/markdoc'
import Markdoc from '@markdoc/markdoc' import Markdoc from '@markdoc/markdoc'
import KeyV from 'keyv'
import { createHash } from 'node:crypto'
export const prerender = false export const prerender = false
const renderCache = new KeyV<string>({
namespace: 'markdoc-rss',
})
const _render = async (content: string) => {
const key = 'html:' + createHash('sha256').update(content).digest('hex')
const cached = await renderCache.get(key)
if (cached) {
return cached
}
const blogTree = Markdoc.transform(Markdoc.parse(content))
const html = Markdoc.renderers.html(blogTree)
await renderCache.set(key, html, 1000 * 60 * 60 * 24)
return html
}
const _getBlog = async (blogId: number) => {
const key = `raw:blog-${blogId}`
const cached = await renderCache.get(key)
if (cached) {
return cached
}
const content = (await getBlog(blogId))?.content
if (content) {
await renderCache.set(key, content, 1000 * 60 * 60 * 12)
}
return content
}
export const GET = (async (context) => { export const GET = (async (context) => {
let blogs: ListBlogItemType[] = [] let blogs: ListBlogItemType[] = []
let pid = 0 let pid = 0
@ -34,18 +70,13 @@ export const GET = (async (context) => {
site, site,
items: await Promise.all( items: await Promise.all(
blogs.map(async (blog) => { blogs.map(async (blog) => {
const blogContent = const blogContent = (await _getBlog(blog.id)) || '博客内容暂不可用'
(await getBlog(blog.id))?.content || '博客内容暂不可用'
// const blogTree = await toMarkdocTree(blogContent)
const blogTree = Markdoc.transform(Markdoc.parse(blogContent))
const html = Markdoc.renderers.html(blogTree)
const rssItem: RSSFeedItem = { const rssItem: RSSFeedItem = {
title: blog.title, title: blog.title,
description: `一篇由 ${blog.author.nickname} 写的博客`, description: `一篇由 ${blog.author.nickname} 写的博客`,
link: `${site}/blogs/${blog.id}`, link: `${site}/blogs/${blog.id}`,
pubDate: new Date(blog.created_at), pubDate: new Date(blog.created_at),
content: html, content: await _render(blogContent),
author: blog.author.nickname, author: blog.author.nickname,
enclosure: blog.featured_image enclosure: blog.featured_image
? { ? {

View File

@ -1,14 +1,9 @@
{ {
"extends": "astro/tsconfigs/strict", "extends": "astro/tsconfigs/strict",
"include": [ "include": [".astro/types.d.ts", "**/*"],
".astro/types.d.ts", "exclude": ["dist"],
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "react" "jsxImportSource": "react"
} }
} }