Full Code of lxchapu/astro-gyoza for AI

main 7261f40b6708 cached
131 files
135.3 KB
43.0k tokens
109 symbols
1 requests
Download .txt
Repository: lxchapu/astro-gyoza
Branch: main
Commit: 7261f40b6708
Files: 131
Total size: 135.3 KB

Directory structure:
gitextract_63w4a159/

├── .gitattributes
├── .github/
│   └── workflows/
│       └── close-inactive-issues.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode/
│   ├── extensions.json
│   └── launch.json
├── LICENSE
├── README.md
├── astro.config.js
├── commitlint.config.js
├── package.json
├── scripts/
│   ├── new-friend.js
│   ├── new-post.js
│   ├── new-project.js
│   └── utils.js
├── src/
│   ├── components/
│   │   ├── AnimatedSignature.tsx
│   │   ├── BackToTopFAB.tsx
│   │   ├── CategoryList.astro
│   │   ├── Flashlight.tsx
│   │   ├── FriendList.astro
│   │   ├── Highlight.astro
│   │   ├── MarkdownWrapper.astro
│   │   ├── ProjectList.astro
│   │   ├── RootPortal.tsx
│   │   ├── SectionBlock.astro
│   │   ├── TagList.astro
│   │   ├── Timeline.astro
│   │   ├── TimelineProgress.tsx
│   │   ├── ToastContainer.tsx
│   │   ├── comment/
│   │   │   ├── Comments.astro
│   │   │   ├── Waline.tsx
│   │   │   └── index.ts
│   │   ├── footer/
│   │   │   ├── Footer.astro
│   │   │   ├── Link.astro
│   │   │   ├── RunningDays.tsx
│   │   │   └── ThemeSwitch.tsx
│   │   ├── head/
│   │   │   ├── AccentColorInjector.astro
│   │   │   ├── CommonHead.astro
│   │   │   ├── PrintVersion.astro
│   │   │   ├── ThemeLoader.astro
│   │   │   ├── WebAnalytics.tsx
│   │   │   └── index.ts
│   │   ├── head-gradient/
│   │   │   ├── HeadGradient.tsx
│   │   │   └── index.ts
│   │   ├── header/
│   │   │   ├── AnimatedLogo.tsx
│   │   │   ├── BluredBackground.tsx
│   │   │   ├── Header.tsx
│   │   │   ├── HeaderContent.tsx
│   │   │   ├── HeaderDrawer.tsx
│   │   │   ├── HeaderMeta.tsx
│   │   │   ├── SearchButton.tsx
│   │   │   └── hooks.ts
│   │   ├── hero/
│   │   │   ├── Hero.astro
│   │   │   └── SocialList.tsx
│   │   ├── post/
│   │   │   ├── ActionAside.tsx
│   │   │   ├── Outdate.tsx
│   │   │   ├── PostArchiveInfo.astro
│   │   │   ├── PostCard.astro
│   │   │   ├── PostCardHoverOverlay.tsx
│   │   │   ├── PostCopyright.tsx
│   │   │   ├── PostList.astro
│   │   │   ├── PostMetaInfo.astro
│   │   │   ├── PostNav.astro
│   │   │   ├── PostPagination.astro
│   │   │   ├── PostToc.tsx
│   │   │   ├── ReadingProgress.tsx
│   │   │   └── RelativeDate.tsx
│   │   ├── provider/
│   │   │   ├── HeaderMetaInfoProvider.tsx
│   │   │   ├── PageScrollInfoProvider.tsx
│   │   │   ├── Provider.tsx
│   │   │   ├── ThemeProvider.tsx
│   │   │   └── ViewportProvider.tsx
│   │   └── ui/
│   │       └── modal/
│   │           ├── Modal.tsx
│   │           ├── ModalStack.tsx
│   │           ├── context.ts
│   │           ├── hooks.ts
│   │           └── index.ts
│   ├── config.json
│   ├── content/
│   │   ├── config.ts
│   │   ├── friends/
│   │   │   ├── Keigo.yml
│   │   │   ├── astro-docs.yaml
│   │   │   └── lxchapu.yaml
│   │   ├── posts/
│   │   │   ├── embed.md
│   │   │   ├── guide.md
│   │   │   ├── how-to-use-icons.md
│   │   │   └── markdown.md
│   │   ├── projects/
│   │   │   └── gyoza.yaml
│   │   └── spec/
│   │       ├── about.md
│   │       ├── friends.md
│   │       └── projects.md
│   ├── env.d.ts
│   ├── hooks/
│   │   └── useDebounceValue.ts
│   ├── layouts/
│   │   ├── Layout.astro
│   │   ├── MarkdownLayout.astro
│   │   └── PageLayout.astro
│   ├── pages/
│   │   ├── 404.astro
│   │   ├── [...page].astro
│   │   ├── [spec].astro
│   │   ├── archives.astro
│   │   ├── categories/
│   │   │   └── [category].astro
│   │   ├── posts/
│   │   │   └── [...slug].astro
│   │   ├── robots.txt.ts
│   │   ├── rss.xml.ts
│   │   └── tags/
│   │       ├── [tag].astro
│   │       └── index.astro
│   ├── plugins/
│   │   ├── rehypeCodeBlock.js
│   │   ├── rehypeCodeHighlight.js
│   │   ├── rehypeHeading.js
│   │   ├── rehypeImage.js
│   │   ├── rehypeLink.js
│   │   ├── rehypeTableBlock.js
│   │   ├── remarkEmbed.js
│   │   ├── remarkReadingTime.js
│   │   └── remarkSpoiler.js
│   ├── store/
│   │   ├── metaInfo.ts
│   │   ├── modalStack.ts
│   │   ├── scrollInfo.ts
│   │   ├── theme.ts
│   │   └── viewport.ts
│   ├── styles/
│   │   ├── global.css
│   │   ├── iconfont.css
│   │   ├── markdown.css
│   │   ├── shiki.css
│   │   ├── signature.css
│   │   └── swup.css
│   └── utils/
│       ├── content.ts
│       ├── date.ts
│       └── theme.ts
├── tailwind.config.ts
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitattributes
================================================
* text=auto eol=lf

================================================
FILE: .github/workflows/close-inactive-issues.yml
================================================
name: Close inactive issues

on:
  schedule:
    - cron: '30 1 * * *'

jobs:
  close-issues:
    if: github.repository == 'lxchapu/astro-gyoza'
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: write
    steps:
      - uses: actions/stale@v9
        with:
          days-before-stale: 14
          days-before-close: 7
          days-before-pr-close: -1
          stale-issue-label: 'stale'
          stale-issue-message: 'This issue is stale because it has been open 14 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
          stale-pr-message: 'This PR is stale because it has been open 14 days with no activity.'
          close-issue-message: 'This issue was closed because it has been inactive for 7 days since being marked as stale.'


================================================
FILE: .gitignore
================================================
# build output
dist/

# generated types
.astro/

# dependencies
node_modules/

# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# environment variables
.env
.env.production

# macOS-specific files
.DS_Store

# jetbrains setting folder
.idea/


================================================
FILE: .prettierignore
================================================
.astro/
node_modules/
dist/
pnpm-lock.yaml

================================================
FILE: .prettierrc
================================================
{
  "printWidth": 100,
  "semi": false,
  "singleQuote": true,
  "plugins": ["prettier-plugin-astro"],
  "overrides": [
    {
      "files": "*.astro",
      "options": {
        "parser": "astro"
      }
    }
  ]
}


================================================
FILE: .vscode/extensions.json
================================================
{
  "recommendations": ["astro-build.astro-vscode"],
  "unwantedRecommendations": []
}


================================================
FILE: .vscode/launch.json
================================================
{
  "version": "0.2.0",
  "configurations": [
    {
      "command": "./node_modules/.bin/astro dev",
      "name": "Development server",
      "request": "launch",
      "type": "node-terminal"
    }
  ]
}


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2024 柃夏chapu

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# Gyoza

Gyoza is a static blog template built with Astro and React.

![astro version](https://img.shields.io/badge/astro-4.6-red)
![node version](https://img.shields.io/badge/node-18.18-green)

Demo Site:

- [gyoza.lxchapu.com](https://gyoza.lxchapu.com)
- [www.lxchapu.com](https://www.lxchapu.com)

Enjoy it!

## 📷 Screenshots

![Preview](https://s2.loli.net/2024/05/06/A9rzC3Uym7RwdQc.webp)

## 🎉 Features

- ✅ 有着规范的 URL 和 OpenGraph 信息,对 SEO 友好
- ✅ 支持站点地图
- ✅ 支持 RSS 订阅
- ✅ 支持夜间模式
- ✅ 特殊日期变灰
- ✅ 简单干净的配色和主题
- ✅ 支持评论系统
- ✅ 支持代码高亮

## 🔧 Tech Stack

- [Astro](https://astro.build/)
- [React](https://reactjs.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Framer Motion](https://www.framer.com/motion/)
- [Jotai](https://jotai.org/)

## 📖 Documentation

前往:[Documentation](https://gyoza.lxchapu.com/posts/guide)

## 🚀 Project Structure

```text
├── public/
├── src/
│   ├── components/
│   ├── content/
│   ├── layouts/
│   ├── pages/
│   ├── plugins/
│   ├── store/
│   ├── styles/
│   ├── utils/
│   └── config.json
├── astro.config.mjs
├── README.md
├── package.json
└── tsconfig.json
```

网站配置保存在 `config.json` 文件。

## 🧞 Commands

| Command        | Action                                       |
| :------------- | :------------------------------------------- |
| `pnpm i`       | Installs dependencies                        |
| `pnpm dev`     | Starts local dev server at `localhost:4321`  |
| `pnpm build`   | Build your production site to `./dist/`      |
| `pnpm preview` | Preview your build locally, before deploying |
| `pnpm format`  | Format code using Prettier                   |


================================================
FILE: astro.config.js
================================================
import { defineConfig } from 'astro/config'
import { remarkReadingTime } from './src/plugins/remarkReadingTime'
import { rehypeCodeBlock } from './src/plugins/rehypeCodeBlock'
import { rehypeTableBlock } from './src/plugins/rehypeTableBlock'
import { rehypeCodeHighlight } from './src/plugins/rehypeCodeHighlight'
import { rehypeImage } from './src/plugins/rehypeImage'
import { rehypeLink } from './src/plugins/rehypeLink'
import { rehypeHeading } from './src/plugins/rehypeHeading'
import remarkDirective from 'remark-directive'
import { remarkSpoiler } from './src/plugins/remarkSpoiler'
import { remarkEmbed } from './src/plugins/remarkEmbed'
import tailwind from '@astrojs/tailwind'
import react from '@astrojs/react'
import sitemap from '@astrojs/sitemap'
import { rehypeHeadingIds } from '@astrojs/markdown-remark'
import { site } from './src/config.json'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import swup from '@swup/astro'

// https://astro.build/config
export default defineConfig({
  site: site.url,
  integrations: [
    tailwind(),
    react(),
    sitemap(),
    swup({
      theme: false,
      animationClass: 'swup-transition-',
      containers: ['main'],
      morph: ['[component-export="Provider"]'],
    }),
  ],
  markdown: {
    syntaxHighlight: false,
    smartypants: false,
    remarkPlugins: [remarkMath, remarkDirective, remarkEmbed, remarkSpoiler, remarkReadingTime],
    rehypePlugins: [
      rehypeHeadingIds,
      rehypeKatex,
      rehypeLink,
      rehypeImage,
      rehypeHeading,
      rehypeCodeBlock,
      rehypeCodeHighlight,
      rehypeTableBlock,
    ],
    remarkRehype: { footnoteLabel: '参考', footnoteBackLabel: '返回正文' },
  },
  vite: {
    build: {
      rollupOptions: {
        external: ['/pagefind/pagefind.js'],
      },
    },
  },
})


================================================
FILE: commitlint.config.js
================================================
export default {
  extends: ['@commitlint/config-conventional'],
}


================================================
FILE: package.json
================================================
{
  "name": "astro-gyoza",
  "type": "module",
  "version": "0.0.1",
  "scripts": {
    "prepare": "pnpm exec simple-git-hooks",
    "dev": "astro dev",
    "build": "astro check && astro build && pagefind --site dist",
    "preview": "astro preview",
    "astro": "astro",
    "lint": "prettier --write .",
    "new-friend": "node scripts/new-friend.js",
    "new-post": "node scripts/new-post.js",
    "new-project": "node scripts/new-project.js"
  },
  "dependencies": {
    "@astrojs/check": "^0.5.10",
    "@astrojs/markdown-remark": "^5.1.0",
    "@astrojs/react": "^3.3.0",
    "@astrojs/rss": "^4.0.5",
    "@astrojs/sitemap": "^3.1.3",
    "@astrojs/tailwind": "^5.1.0",
    "@radix-ui/react-dialog": "^1.0.5",
    "@shikijs/rehype": "^1.3.0",
    "@swup/astro": "^1.4.1",
    "@types/chroma-js": "^2.4.4",
    "@types/lodash-es": "^4.17.12",
    "@types/react": "^18.2.78",
    "@types/react-dom": "^18.2.25",
    "@waline/client": "^3.1.3",
    "astro": "^4.6.1",
    "chroma-js": "^2.4.2",
    "clsx": "^2.1.0",
    "framer-motion": "^11.1.5",
    "hastscript": "^9.0.0",
    "jotai": "^2.8.0",
    "katex": "^0.16.10",
    "lodash-es": "^4.17.21",
    "mdast-util-to-string": "^4.0.0",
    "pagefind": "^1.1.0",
    "qrcode.react": "^3.1.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-toastify": "^10.0.5",
    "reading-time": "^1.5.0",
    "rehype-katex": "^7.0.0",
    "remark-directive": "^3.0.0",
    "remark-math": "^6.0.0",
    "tailwindcss": "^3.4.3",
    "typescript": "^5.4.5",
    "unist-util-visit": "^5.0.0"
  },
  "devDependencies": {
    "@commitlint/cli": "^19.3.0",
    "@commitlint/config-conventional": "^19.2.2",
    "@inquirer/prompts": "^5.0.2",
    "lint-staged": "^15.2.2",
    "micromark-util-symbol": "^2.0.0",
    "prettier": "^3.2.5",
    "prettier-plugin-astro": "^0.13.0",
    "simple-git-hooks": "^2.11.1"
  },
  "simple-git-hooks": {
    "commit-msg": "pnpm exec commitlint --edit $1",
    "pre-commit": "pnpm exec lint-staged"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx.astro,md,css,json}": "prettier --write"
  }
}


================================================
FILE: scripts/new-friend.js
================================================
import { input } from '@inquirer/prompts'
import fs from 'fs'
import path from 'path'
import { isFileNameSafe } from './utils.js'

function getFriendFullPath(fileName) {
  return path.join('./src/content/friends', `${fileName}.yaml`)
}

const fileName = await input({
  message: '请输入文件名称',
  validate: (value) => {
    if (!isFileNameSafe(value)) {
      return '文件名只能包含字母、数字和连字符'
    }
    const fullPath = getFriendFullPath(value)
    if (fs.existsSync(fullPath)) {
      return `${fullPath} 已存在`
    }
    return true
  },
})

const title = await input({
  message: '请输入标题',
})
const description = await input({
  message: '请输入描述',
})
const link = await input({
  message: '请输入地址',
})
const avatar = await input({
  message: '请输入头像地址',
})

const content = `title: ${title}
description: ${description}
link: ${link}
avatar: ${avatar}
`

const fullPath = getFriendFullPath(fileName)
fs.writeFileSync(fullPath, content)
console.log(`${fullPath} 创建成功`)


================================================
FILE: scripts/new-post.js
================================================
import { input } from '@inquirer/prompts'
import fs from 'fs'
import path from 'path'
import { isFileNameSafe } from './utils.js'

function getPostFullPath(fileName) {
  return path.join('./src/content/posts', `${fileName}.md`)
}

const fileName = await input({
  message: '请输入文件名称',
  validate: (value) => {
    if (!isFileNameSafe(value)) {
      return '文件名只能包含字母、数字和连字符'
    }
    const fullPath = getPostFullPath(value)
    if (fs.existsSync(fullPath)) {
      return `${fullPath} 已存在`
    }
    return true
  },
})

const title = await input({
  message: '请输入文章标题',
})

const content = `---
title: ${title}
date: ${new Date().toISOString()}
tags: []
comments: true
draft: false
---
`

const fullPath = getPostFullPath(fileName)
fs.writeFileSync(fullPath, content)
console.log(`${fullPath} 创建成功`)


================================================
FILE: scripts/new-project.js
================================================
import { input } from '@inquirer/prompts'
import fs from 'fs'
import path from 'path'
import { isFileNameSafe } from './utils.js'

function getProjectFullPath(fileName) {
  return path.join('./src/content/projects', `${fileName}.yaml`)
}

const fileName = await input({
  message: '请输入文件名称',
  validate: (value) => {
    if (!isFileNameSafe(value)) {
      return '文件名只能包含字母、数字和连字符'
    }
    const fullPath = getProjectFullPath(value)
    if (fs.existsSync(fullPath)) {
      return `${fullPath} 已存在`
    }
    return true
  },
})

const title = await input({
  message: '请输入项目名称',
})
const description = await input({
  message: '请输入项目描述',
})
const link = await input({
  message: '请输入项目地址',
})
const image = await input({
  message: '请输入预览图片地址',
})

const content = `title: ${title}
description: ${description}
link: ${link}
image: ${image}
`

const fullPath = getProjectFullPath(fileName)
fs.writeFileSync(fullPath, content)
console.log(`${fullPath} 创建成功`)


================================================
FILE: scripts/utils.js
================================================
export function isFileNameSafe(fileName) {
  return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(fileName)
}


================================================
FILE: src/components/AnimatedSignature.tsx
================================================
import Svg from '@/assets/signature.svg?raw'

export function AnimatedSignature() {
  return (
    <div
      className="animated-signature"
      dangerouslySetInnerHTML={{
        __html: Svg,
      }}
    ></div>
  )
}


================================================
FILE: src/components/BackToTopFAB.tsx
================================================
import { useAtomValue } from 'jotai'
import { pageScrollLocationAtom } from '@/store/scrollInfo'
import { AnimatePresence, motion } from 'framer-motion'

export function BackToTopFAB() {
  const scrollY = useAtomValue(pageScrollLocationAtom)
  const isShow = scrollY > 100

  return (
    <div className="fixed right-4 bottom-6 z-10">
      <AnimatePresence>{isShow && <BackToTop />}</AnimatePresence>
    </div>
  )
}

function BackToTop() {
  const handleBackToTop = () => {
    window.scrollTo({
      top: 0,
      behavior: 'smooth',
    })
  }

  return (
    <motion.button
      className="size-10 rounded-full shadow-lg shadow-zinc-800/5 border border-primary bg-white/50 dark:bg-zinc-800/50 backdrop-blur"
      type="button"
      aria-label="Back to top"
      onClick={handleBackToTop}
      initial={{ opacity: 0, scale: 0 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, scale: 0 }}
    >
      <i className="iconfont icon-rocket"></i>
    </motion.button>
  )
}


================================================
FILE: src/components/CategoryList.astro
================================================
---
interface Props {
  categories: {
    name: string
    slug: string
    count: number
  }[]
}

const { categories } = Astro.props
---

{
  categories.length === 0 ? (
    <div class="text-center text-sm">作者懒得分类🤪</div>
  ) : (
    <div class="space-y-2.5">
      {categories.map((category) => (
        <a class="relative block group" href={`/categories/${category.slug}`}>
          <div class="absolute -z-1 inset-0 rounded-lg bg-accent/10 group-hover:transition group-hover:bg-accent/20 group-hover:scale-105" />
          <div class="px-2.5 py-2 text-sm flex gap-1 select-none">
            <i class="shrink-0 iconfont icon-folder" />
            <span class="grow">{category.name}</span>
            <span class="shrink-0">{category.count}</span>
          </div>
        </a>
      ))}
    </div>
  )
}


================================================
FILE: src/components/Flashlight.tsx
================================================
import { useLayoutEffect, useState } from 'react'

export function Flashlight() {
  const [cursorX, setCursorX] = useState(0)
  const [cursorY, setCursorY] = useState(0)
  const isMobile = !window.matchMedia('(hover: hover)').matches

  if (isMobile) {
    return null
  }

  const backgroundImage = `radial-gradient(
    circle 16vmax at ${cursorX}px ${cursorY}px,
    rgba(0, 0, 0, 0) 0%,
    rgba(0, 0, 0, 0.5) 80%,
    rgba(0, 0, 0, 0.8) 100%
  )`

  useLayoutEffect(() => {
    const handleMouseMove = (event: MouseEvent) => {
      setCursorX(event.clientX)
      setCursorY(event.clientY)
    }

    document.addEventListener('mousemove', handleMouseMove)

    return () => {
      document.removeEventListener('mousemove', handleMouseMove)
    }
  }, [])

  return (
    <div
      className="fixed inset-0 z-50 pointer-events-none"
      style={{
        backgroundImage,
        display: isMobile ? 'none' : 'block',
      }}
    ></div>
  )
}


================================================
FILE: src/components/FriendList.astro
================================================
---
import { getCollection } from 'astro:content'

const friends = await getCollection('friends')
---

<ul class="grid grid-cols-1 sm:grid-cols-2 gap-4 md:gap-8">
  {
    friends.map((friend) => (
      <li>
        <a href={friend.data.link} target="_blank" rel="noopener noreferrer external">
          <div class="p-4 flex gap-2 bg-accent/10 rounded-lg group">
            <img
              class="shrink-0 size-16 object-contain rounded-full"
              src={friend.data.avatar}
              alt={`Friend avatar: ${friend.data.title}`}
              loading="lazy"
            />
            <div class="min-w-0 grow">
              <div class="truncate font-bold text-xl group-hover:text-accent">
                {friend.data.title}
              </div>
              <div class="text-sm line-clamp-2">{friend.data.description}</div>
            </div>
            <i class="shrink-0 iconfont icon-external-link group-hover:text-accent" />
          </div>
        </a>
      </li>
    ))
  }
</ul>


================================================
FILE: src/components/Highlight.astro
================================================
---
interface Props {
  class?: string
}

const { class: className } = Astro.props
---

<span class="relative" class:list={[className]}
  ><span class="absolute -z-1 top-[30%] left-0 w-full h-[40%] bg-accent/30 -rotate-3"></span><slot
  /></span
>


================================================
FILE: src/components/MarkdownWrapper.astro
================================================
---
interface Props {
  class?: string
}

const { class: className } = Astro.props
---

<article id="markdown-wrapper" class="markdown" class:list={[className]}>
  <slot />
</article>


================================================
FILE: src/components/ProjectList.astro
================================================
---
import { getCollection } from 'astro:content'

const projects = await getCollection('projects')
---

<ul class="grid grid-cols-1 sm:grid-cols-2 gap-4 md:gap-8">
  {
    projects.map((project) => (
      <li>
        <a href={project.data.link} target="_blank" rel="noopener noreferrer external">
          <div class="rounded-lg bg-accent/10 overflow-hidden group">
            <div class="aspect-video overflow-hidden">
              <img
                class="size-full object-cover transition-transform group-hover:scale-110"
                src={project.data.image}
                alt={`Project cover: ${project.data.title}`}
                loading="lazy"
              />
            </div>
            <div class="p-4">
              <div class="group-hover:text-accent">
                <span class="text-xl font-bold">{project.data.title}</span>
                <i class="ml-2 iconfont icon-external-link" />
              </div>
              <p class="mt-2 text-sm line-clamp-2">{project.data.description}</p>
            </div>
          </div>
        </a>
      </li>
    ))
  }
</ul>


================================================
FILE: src/components/RootPortal.tsx
================================================
import { createPortal } from 'react-dom'

export function RootPortal({
  to = document.body,
  children,
}: {
  to?: HTMLElement
  children: React.ReactNode
}) {
  return createPortal(children, to)
}


================================================
FILE: src/components/SectionBlock.astro
================================================
---
interface Props {
  title: string
}

const { title } = Astro.props
---

<section>
  <div class="mb-8 font-bold uppercase tracking-widest text-accent">{title}</div>
  <slot />
</section>


================================================
FILE: src/components/TagList.astro
================================================
---
interface Props {
  tags: {
    name: string
    slug: string
    count?: number
  }[]
}

const { tags } = Astro.props
---

{
  tags.length === 0 ? (
    <div class="text-center text-sm">作者没有准备标签😦</div>
  ) : (
    <div class="flex flex-wrap gap-2.5">
      {tags.map((tag) => (
        <a class="relative block group" href={`/tags/${tag.slug}`}>
          <div class="absolute -z-1 inset-0 rounded-lg bg-accent/10 group-hover:transition group-hover:bg-accent/20 group-hover:scale-105" />
          <div class="px-2.5 py-2 text-sm flex items-baseline gap-1 select-none">
            <span>{tag.name}</span>
            {tag.count && <span class="text-xs">({tag.count})</span>}
          </div>
        </a>
      ))}
    </div>
  )
}


================================================
FILE: src/components/Timeline.astro
================================================
---
import type { CollectionEntry } from 'astro:content'
import { getShortDate } from '@/utils/date'

interface Props {
  posts: CollectionEntry<'posts'>[]
}

const { posts } = Astro.props

const groupedPosts = posts.reduce<
  {
    year: number
    posts: CollectionEntry<'posts'>[]
  }[]
>((acc, cur) => {
  const year = cur.data.date.getFullYear()
  const lastYearGroup = acc[acc.length - 1]
  if (lastYearGroup && lastYearGroup.year === year) {
    lastYearGroup.posts.push(cur)
  } else {
    acc.push({
      year,
      posts: [cur],
    })
  }
  return acc
}, [])
---

<section>
  {
    groupedPosts.map((year) => (
      <div class="relative py-10">
        <h3
          class="absolute -top-3 -left-8 text-[7rem] text-transparent leading-none font-bold pointer-events-none select-none font-['Atkinson']"
          style="-webkit-text-stroke: 2px rgb(var(--color-text-primary) / 0.1);"
        >
          {year.year}
        </h3>
        <ul class="space-y-4">
          {year.posts.map((post) => (
            <li class="flex items-baseline space-x-2">
              <span class="shrink-0 text-gray-600 dark:text-gray-300 text-sm">
                {getShortDate(post.data.date)}
              </span>
              <a
                class="hover:text-accent/80 hover:underline underline-offset-2"
                href={`/posts/${post.slug}`}
              >
                {post.data.title}
              </a>
              {post.data.sticky > 0 && <i class="iconfont icon-pushpin text-red-500" />}
            </li>
          ))}
        </ul>
      </div>
    ))
  }
</section>


================================================
FILE: src/components/TimelineProgress.tsx
================================================
import { useEffect, useRef, useState } from 'react'
import { animate } from 'framer-motion'
import { getDaysInYear, getDiffInDays, getStartOfDay, getStartOfYear } from '@/utils/date'

export function TimelineProgress() {
  const [currentYear, setCurrentYear] = useState(0)
  const [dayOfYear, setDayOfYear] = useState(0)
  const [percentOfYear, setPercentOfYear] = useState(0)
  const [percentOfToday, setPercentOfToday] = useState(0)

  const updateInfo = () => {
    const now = new Date()
    setCurrentYear(now.getFullYear())

    const pastDays = getDiffInDays(getStartOfYear(now), now)
    setDayOfYear(pastDays)
    setPercentOfYear((pastDays / getDaysInYear(now)) * 100)

    const pastTime = now.getTime() - getStartOfDay(now).getTime()
    setPercentOfToday((pastTime / 86400 / 1000) * 100)
  }

  useEffect(() => {
    updateInfo()
    const interval = setInterval(updateInfo, 1000)
    return () => {
      clearInterval(interval)
    }
  }, [])

  return (
    <>
      <p className="mt-4">
        今天是 {currentYear} 年的第 <CountUp to={dayOfYear} decimals={0} /> 天
      </p>
      <p className="mt-4">
        今年已过 <CountUp to={percentOfYear} decimals={5} />%
      </p>
      <p className="mt-4">
        今天已过 <CountUp to={percentOfToday} decimals={5} />%
      </p>
    </>
  )
}

function CountUp({
  to,
  decimals,
  duration = 1,
}: {
  to: number
  decimals: number
  duration?: number
}) {
  const node = useRef<HTMLSpanElement>(null)
  const prev = useRef(0)

  useEffect(() => {
    if (!node.current) return

    const control = animate(prev.current, to, {
      duration,
      onUpdate: (value) => {
        node.current!.textContent = value.toFixed(decimals)
      },
    })
    prev.current = to

    return () => {
      control.stop()
    }
  }, [to, decimals, duration])

  return <span ref={node}></span>
}


================================================
FILE: src/components/ToastContainer.tsx
================================================
import { ToastContainer as ReactToastContainer } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css';

export function ToastContainer() {
  return <ReactToastContainer position='bottom-right' autoClose={3000} hideProgressBar closeButton={CloseButton}
    toastClassName="!bg-primary !text-primary text-sm border border-primary"
  />
}

function CloseButton({ closeToast }: { closeToast: (event: React.MouseEvent<HTMLElement>) => void }) {
  return <button type="button" aria-label='Close Toast' className='text-lg opacity-50 hover:opacity-100' onClick={closeToast}>
    <i className='iconfont icon-close'></i>
  </button>
}

================================================
FILE: src/components/comment/Comments.astro
================================================
---
import { Waline } from './Waline'
import { waline } from '@/config.json'
---

<div>
  {waline.serverURL && <Waline {...waline} client:visible />}
</div>


================================================
FILE: src/components/comment/Waline.tsx
================================================
import { useEffect, useRef } from 'react'
import { init } from '@waline/client'
import '@waline/client/style'

export function Waline({ serverURL }: { serverURL: string }) {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const walineInst = init({
      el: ref.current,
      serverURL,
      dark: "[data-theme='dark']",
      login: 'force',
      imageUploader: false,
      search: false,
      locale: {
        placeholder: '发条友善的评论吧(支持 Markdown 语法)…',
      },
      emoji: ['//unpkg.com/@waline/emojis@1.1.0/bilibili'],
    })

    return () => {
      if (ref.current) {
        walineInst?.destroy()
      }
    }
  }, [serverURL])

  return <div ref={ref}></div>
}


================================================
FILE: src/components/comment/index.ts
================================================
export { default as Comments } from './Comments.astro'


================================================
FILE: src/components/footer/Footer.astro
================================================
---
import { ThemeSwitch } from './ThemeSwitch'
import { author, footer } from '@/config.json'
import { getAllPostsWordCount } from '@/utils/content'
import { RunningDays } from './RunningDays'
import Link from './Link.astro'

const sinceYear = new Date(footer.startTime).getFullYear()
const thisYear = new Date().getFullYear()
const copyDate = sinceYear === thisYear ? thisYear : `${sinceYear} - ${thisYear}`

const wordCount = await getAllPostsWordCount()

const wordCountStr = (wordCount / 1000).toFixed(1) + 'k'
---

<footer class="relative z-1 py-8 px-4 md:px-8 border-t border-primary text-secondary text-sm">
  <div class="text-center space-y-2">
    <div>
      Powered by
      <Link href="https://astro.build/">Astro</Link>
      & Designed by
      <Link href="https://github.com/lxchapu/astro-gyoza">Gyoza</Link>
    </div>
    <div class="space-x-1">
      <span>&copy;{copyDate} <Link href="/">{author.name}</Link>.</span>
      <Link href="/rss.xml" data-no-swup>
        <i class="iconfont icon-rss"></i>
        <span>RSS</span>
      </Link>
      <Link href="/sitemap-index.xml" data-no-swup>
        <i class="iconfont icon-map"></i>
        <span>站点地图</span>
      </Link>
    </div>
    <div>
      <RunningDays client:only="react" />
      <span class="select-none opacity-50">|</span>
      <span>共嘚嘚了 {wordCountStr} 字</span>
    </div>
  </div>
  <div class="mt-4 flex justify-center">
    <ThemeSwitch client:only="react" />
  </div>
</footer>


================================================
FILE: src/components/footer/Link.astro
================================================
---
import type { HTMLAttributes } from 'astro/types'

interface Props extends HTMLAttributes<'a'> {
  href: string
}

const { href, ...attrs } = Astro.props

const isExternal = href.startsWith('http')
---

<a
  class="hover:text-accent"
  href={href}
  target={isExternal ? '_blank' : undefined}
  rel={isExternal ? 'noopener noreferrer' : undefined}
  {...attrs}><slot /></a
>


================================================
FILE: src/components/footer/RunningDays.tsx
================================================
import { useLayoutEffect, useState } from 'react'
import { footer } from '@/config.json'
import { getDiffInDays } from '@/utils/date'

export function RunningDays() {
  const [days, setDays] = useState(0)

  useLayoutEffect(() => {
    const diffDays = getDiffInDays(new Date(footer.startTime))
    setDays(diffDays)
  }, [])

  if (days < 0) {
    return <span>Ops! 网站还没有发布</span>
  }

  return <span>已经运行了 {days} 天</span>
}


================================================
FILE: src/components/footer/ThemeSwitch.tsx
================================================
import { themeAtom } from '@/store/theme'
import { useAtom } from 'jotai'

export function ThemeSwitch() {
  const [theme, setTheme] = useAtom(themeAtom)

  const left = { light: 4, system: 36, dark: 68 }[theme]

  return (
    <div className="relative inline-block">
      <div
        className="absolute -z-1 top-1 size-[32px] rounded-full bg-primary transition-transform shadow"
        style={{
          transform: `translateX(${left}px)`,
        }}
      ></div>
      <div
        className="p-[3px] flex rounded-full border border-primary"
        role="radiogroup"
      >
        <button
          className="size-[32px] flex items-center justify-center"
          type="button"
          aria-label="Switch to light theme"
          onClick={() => setTheme('light')}
        >
          <i className="iconfont icon-sun"></i>
        </button>
        <button
          className="size-[32px] flex items-center justify-center"
          type="button"
          aria-label="Switch to system theme"
          onClick={() => setTheme('system')}
        >
          <i className="iconfont icon-computer"></i>
        </button>
        <button
          className="size-[32px] flex items-center justify-center"
          type="button"
          aria-label="Switch to dark theme"
          onClick={() => setTheme('dark')}
        >
          <i className="iconfont icon-moon"></i>
        </button>
      </div>
    </div>
  )
}


================================================
FILE: src/components/head/AccentColorInjector.astro
================================================
<script>
  import chroma from 'chroma-js'
  import { color as themeColor } from '@/config.json'

  function pickRandomAccent() {
    const seed = (Math.random() * themeColor.accent.length) | 0
    return themeColor.accent[seed]
  }

  function getRgbVal(color: chroma.Color) {
    return color.rgb().join(' ')
  }

  function injectColor() {
    const accentColor = pickRandomAccent()

    const rootBgColor = {
      light: chroma.mix('rgb(250,250,250)', accentColor.light, 0.05, 'rgb'),
      dark: chroma.mix('rgb(0,2,18)', accentColor.dark, 0.12, 'rgb'),
    }

    const $style = document.createElement('style')
    $style.textContent = `html {
  --color-accent: ${getRgbVal(chroma(accentColor.light))};
  --color-bg-root: ${getRgbVal(rootBgColor.light)};
  --color-bg-primary: ${getRgbVal(chroma(themeColor.bg.primary.light))};
  --color-bg-secondary: ${getRgbVal(chroma(themeColor.bg.secondary.light))};
  --color-text-primary: ${getRgbVal(chroma(themeColor.text.primary.light))};
  --color-text-secondary: ${getRgbVal(chroma(themeColor.text.secondary.light))};
  --color-border-primary: ${getRgbVal(chroma(themeColor.border.primary.light))};
}
[data-theme='dark'] {
  --color-accent: ${getRgbVal(chroma(accentColor.dark))};
  --color-bg-root: ${getRgbVal(rootBgColor.dark)};
  --color-bg-primary: ${getRgbVal(chroma(themeColor.bg.primary.dark))};
  --color-bg-secondary: ${getRgbVal(chroma(themeColor.bg.secondary.dark))};
  --color-text-primary: ${getRgbVal(chroma(themeColor.text.primary.dark))};
  --color-text-secondary: ${getRgbVal(chroma(themeColor.text.secondary.dark))};
  --color-border-primary: ${getRgbVal(chroma(themeColor.border.primary.dark))};
}`
    document.head.appendChild($style)
  }

  injectColor()

  document.addEventListener('swup:content:replace', injectColor)
</script>


================================================
FILE: src/components/head/CommonHead.astro
================================================
---
import { site, author } from '@/config.json'

interface Props {
  title?: string
  description?: string
  image?: string
}

const { title = site.title, description = site.description, image } = Astro.props

const titleWithSiteSuffix = title === site.title ? title : `${title} - ${site.title}`
---

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<link rel="apple-touch-icon" sizes="180x180" href={site.appleTouchIcon} />
<link rel="icon" href={site.favicon} />
<!-- L'Internationale, 
  Sera le genre humain. -->
<title>{titleWithSiteSuffix}</title>
<meta name="author" content={author.name} />
<meta name="description" content={description} />
<meta name="keywords" content={site.keywords} />
<meta name="generator" content={Astro.generator} />

<meta property="og:url" content={Astro.url} />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
{image && <meta property="og:image" content={new URL(image, Astro.url)} />}

<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
{author.twitterId && <meta property="twitter:site" content={author.twitterId} />}
{image && <meta property="twitter:image" content={new URL(image, Astro.url)} />}

<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site)} />
<link rel="sitemap" href="/sitemap-index.xml" />
<link
  rel="alternate"
  type="application/rss+xml"
  title={site.title}
  href={new URL('/rss.xml', Astro.url)}
/>

================================================
FILE: src/components/head/PrintVersion.astro
================================================
<script>
  import { version } from '@/../package.json'
  
  console.log(
    `%c Gyoza ${version} %c https://gyoza.lxchapu.com `,
    'color: #fff; margin: 1em 0; padding: 5px 0; background: #ef8f99;',
    'margin: 1em 0; padding: 5px 0; background: #efefef;'
  )
</script>


================================================
FILE: src/components/head/ThemeLoader.astro
================================================
<script>
  import { getLocalTheme, getSystemTheme, changePageTheme } from '@/utils/theme'

  // 哀悼日
  const MOURNING_DAYS = ['4-4', '5-12', '7-7', '9-18', '12-13']

  function isMourningDay() {
    const today = new Date()
    const dateStr = `${today.getMonth() + 1}-${today.getDate()}`
    return MOURNING_DAYS.includes(dateStr)
  }

  function load() {
    const localTheme = getLocalTheme()
    const systemTheme = getSystemTheme()
    if (localTheme === 'system') {
      changePageTheme(systemTheme)
    } else {
      changePageTheme(localTheme)
    }
    if (isMourningDay()) {
      document.documentElement.classList.add('gray')
    }
  }

  load()

  // document.addEventListener('swup:content:replace', load)
</script>


================================================
FILE: src/components/head/WebAnalytics.tsx
================================================
import { analytics } from '@/config.json'

export function WebAnalytics() {
  if (import.meta.env.DEV || !analytics.enable) return null

  return <>
    {
      analytics.umami.websiteId && <UmamiAnalytics {...analytics.umami} />
    }
    {
      analytics.google.measurementId && <GoogleAnalytics {...analytics.google} />
    }
    {
      analytics.microsoftClarity.projectId && <MicrosoftClarity {...analytics.microsoftClarity} />
    }
  </>
}

function UmamiAnalytics({
  serverUrl,
  websiteId,
}: {
  serverUrl?: string,
  websiteId: string,
}) {
  const src = `${serverUrl || 'https://cloud.umami.is'}/script.js`

  return <script defer src={src} data-website-id={websiteId} />
}


function GoogleAnalytics({
  measurementId,
}: {
  measurementId: string,
}) {
  return (
    <>
      <script async src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}></script>
      <script dangerouslySetInnerHTML={{
        __html: `window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());

gtag('config', '${measurementId}');`,
      }}></script>
    </>
  )
}


function MicrosoftClarity({
  projectId,
}: {
  projectId: string,
}) {
  return (
    <>
      <script dangerouslySetInnerHTML={{
        __html: `(function(c,l,a,r,i,t,y){
            c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
            t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
            y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
          })(window, document, "clarity", "script", "${projectId}");`
      }}></script>
    </>
  )
}

================================================
FILE: src/components/head/index.ts
================================================
export { default as AccentColorInjector } from './AccentColorInjector.astro'
export { default as CommonHead } from './CommonHead.astro'
export { default as PrintVersion } from './PrintVersion.astro'
export { default as ThemeLoader } from './ThemeLoader.astro'
export { WebAnalytics } from './WebAnalytics'


================================================
FILE: src/components/head-gradient/HeadGradient.tsx
================================================
import { motion } from 'framer-motion'

export function HeadGradient() {
  return (
    <motion.div
      className="absolute -z-1 top-0 inset-x-0 h-[350px] bg-gradient-to-r from-accent/5 to-accent/15"
      style={{
        maskImage: 'linear-gradient(black, transparent)',
      }}
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
    ></motion.div>
  )
}


================================================
FILE: src/components/head-gradient/index.ts
================================================
export { HeadGradient } from './HeadGradient'


================================================
FILE: src/components/header/AnimatedLogo.tsx
================================================
import { AnimatePresence, motion } from 'framer-motion'
import { useShouldHeaderMetaShow, useIsMobile } from './hooks'
import { author } from '@/config.json'

export function AnimatedLogo() {
  const isMobile = useIsMobile()
  const shouldHeaderMetaShow = useShouldHeaderMetaShow()

  if (!isMobile) {
    return <Logo />
  }

  return (
    <AnimatePresence>
      {!shouldHeaderMetaShow && (
        <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
          <Logo />
        </motion.div>
      )}
    </AnimatePresence>
  )
}

function Logo() {
  return (
    <a className="block" href="/" title="Nav to home">
      <img
        className="size-[40px] select-none object-cover rounded-2xl"
        src={author.avatar}
        alt="Site owner avatar"
      />
    </a>
  )
}


================================================
FILE: src/components/header/BluredBackground.tsx
================================================
import { useHeaderBgOpacity } from './hooks'

export function BluredBackground() {
  const opacity = useHeaderBgOpacity()

  return (
    <div
      className="absolute inset-0 -z-1 border-b border-primary bg-white/70 dark:bg-zinc-800/70 backdrop-saturate-150 backdrop-blur-lg transform-gpu"
      style={{
        opacity,
      }}
    ></div>
  )
}


================================================
FILE: src/components/header/Header.tsx
================================================
import { BluredBackground } from './BluredBackground'
import { HeaderContent } from './HeaderContent'
import { SearchButton } from './SearchButton'
import { AnimatedLogo } from './AnimatedLogo'
import { HeaderMeta } from './HeaderMeta'
import { HeaderDrawer } from './HeaderDrawer'
import { useIsMobile } from './hooks'

export function Header() {
  const isMobile = useIsMobile()

  return (
    <header className="fixed top-0 inset-x-0 h-[64px] z-10 overflow-hidden">
      <BluredBackground />
      <div className="max-w-[1100px] h-full md:px-4 mx-auto grid grid-cols-[64px_auto_64px]">
        <div className="flex items-center justify-center">
          {isMobile ? <HeaderDrawer /> : <AnimatedLogo />}
        </div>
        <div className="relative flex items-center justify-center">
          {isMobile ? <AnimatedLogo /> : <HeaderContent />}
          <HeaderMeta />
        </div>
        <div className="flex items-center justify-center">
          <SearchButton />
        </div>
      </div>
    </header>
  )
}


================================================
FILE: src/components/header/HeaderContent.tsx
================================================
import { useState } from 'react'
import { menus } from '@/config.json'
import { clsx } from 'clsx'
import { AnimatePresence, motion } from 'framer-motion'
import {
  usePathName,
  useShouldAccessibleMenuShow,
  useShouldHeaderMenuBgShow,
  useShouldHeaderMetaShow,
} from './hooks'
import { RootPortal } from '@/components/RootPortal'

export function HeaderContent() {
  return (
    <>
      <AnimatedMenu />
      <AccessibleMenu />
    </>
  )
}

function AnimatedMenu() {
  const shouldBgShow = useShouldHeaderMenuBgShow()
  const shouldHeaderMetaShow = useShouldHeaderMetaShow()

  return (
    <AnimatePresence>
      {!shouldHeaderMetaShow && (
        <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
          <HeaderMenu isBgShow={shouldBgShow} />
        </motion.div>
      )}
    </AnimatePresence>
  )
}

function AccessibleMenu() {
  const shouldShow = useShouldAccessibleMenuShow()

  return (
    <RootPortal>
      <AnimatePresence>
        {shouldShow && (
          <motion.div
            className="fixed z-10 top-12 inset-x-0 flex justify-center pointer-events-none"
            initial={{ y: -20 }}
            animate={{ y: 0 }}
            exit={{ y: -20, opacity: 0 }}
          >
            <HeaderMenu isBgShow />
          </motion.div>
        )}
      </AnimatePresence>
    </RootPortal>
  )
}

function HeaderMenu({ isBgShow }: { isBgShow: boolean }) {
  const pathName = usePathName()
  const [mouseX, setMouseX] = useState(0)
  const [mouseY, setMouseY] = useState(0)
  const [radius, setRadius] = useState(0)

  const background = `radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, rgb(var(--color-accent) / 0.12) 0%, transparent 65%)`

  const handleMouseMove = ({ clientX, clientY, currentTarget }: React.MouseEvent) => {
    const bounds = currentTarget.getBoundingClientRect()
    setMouseX(clientX - bounds.left)
    setMouseY(clientY - bounds.top)
    setRadius(Math.sqrt(bounds.width ** 2 + bounds.height ** 2) / 2.5)
  }

  return (
    <nav
      className={clsx('relative rounded-full group pointer-events-auto duration-200', {
        'bg-gradient-to-b from-zinc-50/70 to-white/90 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur-md dark:from-zinc-900/70 dark:to-zinc-800/90 dark:ring-zinc-100/10':
          isBgShow,
      })}
      onMouseMove={handleMouseMove}
    >
      <div
        className="absolute -z-1 -inset-px rounded-full opacity-0 group-hover:opacity-100 duration-500"
        style={{ background }}
        aria-hidden
      ></div>
      <div className="text-sm px-4 flex">
        {menus.map((menu) => (
          <HeaderMenuItem
            key={menu.name}
            href={menu.link}
            title={menu.name}
            icon={menu.icon}
            isActive={pathName === menu.link}
          />
        ))}
      </div>
    </nav>
  )
}

function HeaderMenuItem({
  href,
  isActive,
  title,
  icon,
}: {
  href: string
  isActive: boolean
  title: string
  icon: string
}) {
  return (
    <a
      className={clsx('relative block px-4 py-1.5', isActive ? 'text-accent' : 'hover:text-accent')}
      href={href}
    >
      <div className="flex space-x-2">
        {isActive && (
          <motion.i
            className={clsx('iconfont', icon)}
            initial={{ y: 10, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
          ></motion.i>
        )}
        <span>{title}</span>
      </div>
      {isActive && (
        <div className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-accent/70 to-transparent"></div>
      )}
    </a>
  )
}


================================================
FILE: src/components/header/HeaderDrawer.tsx
================================================
import { menus } from '@/config.json'
import { createContext, useContext, useState, forwardRef } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { motion, AnimatePresence } from 'framer-motion'
import clsx from 'clsx'

const contentVariants = {
  hidden: {
    x: '-100%',
    transition: {
      duration: 0.2,
      ease: 'easeOut',
    },
  },
  visible: {
    x: 0,
    transition: {
      staggerChildren: 0.1,
      delayChildren: 0.1,
      duration: 0.2,
      ease: 'easeOut',
    },
  },
}

const menuItemVariants = {
  hidden: {
    opacity: 0,
    x: '-100%',
  },
  visible: {
    opacity: 1,
    x: 0,
  },
}

export function HeaderDrawer({ zIndex = 999 }: { zIndex?: number }) {
  const [isOpen, setIsOpen] = useState(false)
  const overlayZIndex = zIndex - 1
  const contentZIndex = zIndex

  return (
    <Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
      <Dialog.Trigger asChild>
        <TriggerButton />
      </Dialog.Trigger>

      <AnimatePresence>
        {isOpen && (
          <Dialog.Portal forceMount>
            <Dialog.Overlay asChild>
              <motion.div
                className="fixed inset-0 bg-gray-800/40"
                style={{ zIndex: overlayZIndex }}
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0, transition: { delay: 0.1 } }}
              ></motion.div>
            </Dialog.Overlay>

            <Dialog.Content asChild>
              <motion.div
                className="fixed left-0 inset-y-0 h-full bg-primary rounded-r-lg p-4 flex flex-col justify-center w-[260px] max-w-[80%]"
                style={{ zIndex: contentZIndex }}
                variants={contentVariants}
                initial="hidden"
                animate="visible"
                exit="hidden"
              >
                <DrawerContext.Provider
                  value={{
                    dismiss() {
                      setIsOpen(false)
                    },
                  }}
                >
                  <DrawerContentImpl />
                </DrawerContext.Provider>
              </motion.div>
            </Dialog.Content>
          </Dialog.Portal>
        )}
      </AnimatePresence>
    </Dialog.Root>
  )
}

const TriggerButton = forwardRef<HTMLButtonElement>((props, ref) => {
  return (
    <button
      ref={ref}
      className="size-9 rounded-full shadow-lg shadow-zinc-800/5 border border-primary bg-white/50 dark:bg-zinc-800/50 backdrop-blur"
      type="button"
      aria-label="Open menu"
      {...props}
    >
      <i className="iconfont icon-menu"></i>
    </button>
  )
})

function DrawerContentImpl() {
  const { dismiss } = useContext(DrawerContext)

  return (
    <ul className="mt-8 pb-8 overflow-y-auto overflow-x-hidden min-h-0">
      {menus.map((menu) => (
        <motion.li key={menu.name} variants={menuItemVariants}>
          <a className="inline-flex p-2 space-x-4" href={menu.link} onClick={dismiss}>
            <i className={clsx('iconfont', menu.icon)}></i>
            <span>{menu.name}</span>
          </a>
        </motion.li>
      ))}
    </ul>
  )
}

const DrawerContext = createContext<{ dismiss(): void }>(null!)


================================================
FILE: src/components/header/HeaderMeta.tsx
================================================
import { site } from '@/config.json'
import { AnimatePresence, motion } from 'framer-motion'
import { useHeaderMetaInfo, useShouldHeaderMetaShow } from './hooks'

export function HeaderMeta() {
  const { title, description, slug } = useHeaderMetaInfo()
  const shouldShow = useShouldHeaderMetaShow()

  return (
    <AnimatePresence>
      {shouldShow && (
        <motion.div
          className="absolute inset-0 z-1 flex items-center justify-between md:px-10"
          initial={{
            opacity: 0,
            y: 20,
          }}
          animate={{
            opacity: 1,
            y: 0,
          }}
          exit={{
            opacity: 0,
            y: 20,
          }}
        >
          <div className="grow min-w-0">
            <div className="text-secondary text-xs truncate">{description}</div>
            <h2 className="truncate text-lg">{title}</h2>
          </div>
          <div className="hidden md:block min-w-0 text-right">
            <div className="text-secondary text-xs truncate">{slug}</div>
            <div>{site.title}</div>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  )
}


================================================
FILE: src/components/header/SearchButton.tsx
================================================
import { motion } from 'framer-motion'
import { useCurrentModal, useModal } from '@/components/ui/modal'
import { useEffect, useState } from 'react'
import { useDebounceValue } from '@/hooks/useDebounceValue'

let pagefind: any = null
async function loadPagefind() {
  if (import.meta.env.PROD && !pagefind) {
    const url = '/pagefind/pagefind.js'
    pagefind = await import(/* @vite-ignore */ url)
  }
}

export function SearchButton() {
  const { present } = useModal()

  const openModal = () => {
    present({
      content: <SearchPanel />,
    })
  }

  useSearchKeyboardEvents({ onOpen: openModal })

  return (
    <button
      className="size-9 rounded-full shadow-lg shadow-zinc-800/5 border border-primary bg-white/50 dark:bg-zinc-800/50 backdrop-blur"
      type="button"
      aria-label="Search"
      onClick={openModal}
    >
      <i className="iconfont icon-search"></i>
    </button>
  )
}

function SearchPanel() {
  const [keyword, setKeyword] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [results, setResults] = useState<any[]>([])
  const debouncedKeyword = useDebounceValue(keyword, 350)

  const { dismiss } = useCurrentModal()

  async function search(value: string) {
    if (!value) {
      setResults([])
      return
    }
    setIsLoading(true)
    await loadPagefind()
    if (pagefind) {
      const res = await pagefind.search(value)
      const nextResults = await Promise.all(res.results.map((r: any) => r.data()))
      setResults(nextResults)
    }
    setIsLoading(false)
  }

  useEffect(() => {
    search(debouncedKeyword)
  }, [debouncedKeyword])

  let resultList = null
  if (import.meta.env.DEV) {
    resultList = (
      <div className="h-full flex items-center justify-center">
        <div className="flex gap-2">
          <svg xmlns="http://www.w3.org/2000/svg" width="2em" viewBox="0 0 24 24">
            <path
              fill="currentColor"
              d="M4 20v-6a8 8 0 1 1 16 0v6h1v2H3v-2zm2 0h12v-6a6 6 0 0 0-12 0zm5-18h2v3h-2zm8.778 2.808l1.414 1.414l-2.12 2.121l-1.415-1.414zM2.808 6.222l1.414-1.414l2.121 2.12L4.93 8.344zM7 14a5 5 0 0 1 5-5v2a3 3 0 0 0-3 3z"
            />
          </svg>
          <div>
            <div className="font-semibold mb-1">抱歉</div>
            <div className="text-sm">该功能基于 pagefind,请在构建后再次尝试。</div>
          </div>
        </div>
      </div>
    )
  } else if (isLoading) {
    resultList = (
      <div className="h-full flex items-center justify-center">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="2em"
          viewBox="0 0 24 24"
          className="animate-spin"
        >
          <path
            fill="currentColor"
            d="M12 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1m0 15a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1m8.66-10a1 1 0 0 1-.366 1.366l-2.598 1.5a1 1 0 1 1-1-1.732l2.598-1.5A1 1 0 0 1 20.66 7M7.67 14.5a1 1 0 0 1-.367 1.366l-2.598 1.5a1 1 0 1 1-1-1.732l2.598-1.5a1 1 0 0 1 1.366.366M20.66 17a1 1 0 0 1-1.366.366l-2.598-1.5a1 1 0 0 1 1-1.732l2.598 1.5A1 1 0 0 1 20.66 17M7.67 9.5a1 1 0 0 1-1.367.366l-2.598-1.5a1 1 0 1 1 1-1.732l2.598 1.5A1 1 0 0 1 7.67 9.5"
          />
        </svg>
      </div>
    )
  } else if (keyword.length === 0) {
    resultList = (
      <div className="h-full flex items-center justify-center">
        <svg xmlns="http://www.w3.org/2000/svg" width="2em" viewBox="0 0 24 24">
          <path
            fill="currentColor"
            d="m18.031 16.617l4.283 4.282l-1.415 1.415l-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9s9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617m-2.006-.742A6.98 6.98 0 0 0 18 11c0-3.867-3.133-7-7-7s-7 3.133-7 7s3.133 7 7 7a6.98 6.98 0 0 0 4.875-1.975z"
          />
        </svg>
      </div>
    )
  } else if (results.length === 0) {
    resultList = (
      <div className="h-full flex items-center justify-center">
        <div className="flex flex-col items-center gap-2">
          <svg xmlns="http://www.w3.org/2000/svg" width="2em" viewBox="0 0 24 24">
            <path
              fill="currentColor"
              d="M11 11v2l-5.327 6H11v2H3v-2l5.326-6H3v-2zm10-8v2l-5.327 6H21v2h-8v-2l5.326-6H13V3z"
            />
          </svg>
          <div>无内容</div>
        </div>
      </div>
    )
  } else {
    resultList = (
      <>
        <div className="text-sm px-3 mb-2">找到以下 {results.length} 条结果</div>
        {results.map((item) => {
          return (
            <a
              href={item.url}
              key={item.url}
              className="hover:bg-accent/10 rounded block px-3 py-2"
              onClick={dismiss}
            >
              <div className="font-semibold">{item.meta.title}</div>
              <p className="text-sm" dangerouslySetInnerHTML={{ __html: item.excerpt }}></p>
            </a>
          )
        })}
      </>
    )
  }

  return (
    <motion.div
      className="bg-primary rounded-lg w-[90vw] h-[80vh] max-w-[680px] max-h-[480px] border border-primary flex flex-col"
      initial={{ y: 20, opacity: 0 }}
      animate={{ y: 0, opacity: 1 }}
      exit={{ y: 20, opacity: 0 }}
    >
      <input
        className="px-4 py-3 outline-none bg-transparent border-b border-primary"
        type="text"
        placeholder="Search..."
        maxLength={64}
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
      />
      <div className="px-4 py-3 overflow-y-auto grow">{resultList}</div>
      <div className="px-3 py-2 flex justify-end">
        <a
          href="https://pagefind.app/"
          target="_blank"
          rel="noopener noreferrer"
          className="flex items-center "
        >
          <span className="mr-2 text-xs">Search by</span>
          <span className="font-semibold">pagefind</span>
        </a>
      </div>
    </motion.div>
  )
}

function useSearchKeyboardEvents({ onOpen }: { onOpen: () => void }) {
  useEffect(() => {
    function handleKeyDown(event: KeyboardEvent) {
      if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
        event.preventDefault()
        onOpen()
      }
    }

    window.addEventListener('keydown', handleKeyDown)

    return () => {
      window.removeEventListener('keydown', handleKeyDown)
    }
  }, [onOpen])
}


================================================
FILE: src/components/header/hooks.ts
================================================
import { useAtomValue } from 'jotai'
import {
  pathNameAtom,
  metaTitleAtom,
  metaDescriptionAtom,
  metaSlugAtom,
  hasMetaInfoAtom,
} from '@/store/metaInfo'
import { pageScrollLocationAtom, pageScrollDirectionAtom } from '@/store/scrollInfo'
import { isMobileAtom } from '@/store/viewport'
import { floor } from 'lodash-es'

const threshold = 60

export function useHeaderBgOpacity() {
  const scrollY = useAtomValue(pageScrollLocationAtom)
  if (scrollY >= threshold * 2) {
    return 1
  } else if (scrollY <= threshold) {
    return 0
  } else {
    return floor((scrollY - threshold) / threshold, 2)
  }
}

export function useHasMetaInfo() {
  return useAtomValue(hasMetaInfoAtom)
}

export function useShouldHeaderMenuBgShow() {
  const scrollY = useAtomValue(pageScrollLocationAtom)
  return scrollY < threshold
}

export function useIsMobile() {
  return useAtomValue(isMobileAtom)
}

export function useShouldHeaderMetaShow() {
  const hasMetaInfo = useHasMetaInfo()
  const scrollY = useAtomValue(pageScrollLocationAtom)

  return hasMetaInfo && scrollY >= threshold
}

export function useHeaderMetaInfo() {
  const title = useAtomValue(metaTitleAtom)
  const description = useAtomValue(metaDescriptionAtom)
  const slug = useAtomValue(metaSlugAtom)

  return {
    title,
    description,
    slug,
  }
}

export function usePathName() {
  return useAtomValue(pathNameAtom)
}

export function useShouldAccessibleMenuShow() {
  const scrollY = useAtomValue(pageScrollLocationAtom)
  const scrollDirection = useAtomValue(pageScrollDirectionAtom)
  const hasMetaInfo = useHasMetaInfo()

  return hasMetaInfo && scrollY >= 400 && scrollDirection === 'up'
}


================================================
FILE: src/components/hero/Hero.astro
================================================
---
import { hero, author } from '@/config.json'
import { SocialList } from './SocialList'
import Highlight from '@/components/Highlight.astro'
---

<div class="lg:-mt-16 lg:h-dvh lg:min-h-[720px]">
  <div
    class="relative max-w-[1300px] mx-auto h-full px-4 grid lg:grid-cols-2 items-center justify-items-center"
  >
    <div class="mt-[120px] lg:mt-0 max-w-[590px]">
      <h1 class="text-3xl text-center lg:text-left text-balance">
        Hi there, I'm <Highlight class="font-bold">{hero.name}</Highlight>👋<br />{hero.bio}
      </h1>
      <div class="text-sm text-secondary mt-3 text-center lg:text-left">{hero.description}</div>
      <SocialList className="mt-[60px]" client:load />
    </div>
    <div class="mt-20 lg:mt-0">
      <div
        class="size-[200px] lg:size-[300px] rounded-full overflow-hidden border border-primary bg-zinc-100 dark:bg-zinc-800"
      >
        <img class="size-full" src={author.avatar} alt="Site owner avatar" loading="lazy" />
      </div>
    </div>

    <div class="mt-10 lg:mt-0 lg:absolute inset-x-0 bottom-0 flex flex-col items-center">
      <p class="text-xs text-center text-balance text-secondary">
        {hero.yiyan}
      </p>
      <div class="mt-7 text-xl animate-bounce">
        <i class="iconfont icon-down"></i>
      </div>
    </div>
  </div>
</div>


================================================
FILE: src/components/hero/SocialList.tsx
================================================
import clsx from 'clsx'
import { hero } from '@/config.json'
import { motion } from 'framer-motion'

const itemVariants = {
  hidden: {
    opacity: 0,
    y: 40,
  },
  visible: {
    opacity: 1,
    y: 0,
  },
}

export function SocialList({ className }: { className?: string }) {
  return (
    <motion.ul
      className={clsx(
        'flex gap-4 flex-wrap items-center justify-center lg:justify-start',
        className,
      )}
      initial="hidden"
      animate="visible"
      transition={{
        staggerChildren: 0.1,
      }}
    >
      {hero.socials.map((social) => (
        <motion.li key={social.name} variants={itemVariants}>
          <a
            className="relative size-9 text-white text-xl flex justify-center items-center group"
            href={social.url}
            title={social.name}
            target="_blank"
            rel="noopener noreferrer"
          >
            <span
              className="absolute inset-0 -z-1 rounded-full group-hover:scale-105 transition"
              style={{ backgroundColor: social.color }}
            ></span>
            <i className={clsx('iconfont', social.icon)} />
          </a>
        </motion.li>
      ))}
    </motion.ul>
  )
}


================================================
FILE: src/components/post/ActionAside.tsx
================================================
import { sponsor, site } from '@/config.json'
import { motion } from 'framer-motion'
import * as QR from 'qrcode.react'
import { useAtomValue } from 'jotai'
import { metaSlugAtom, metaTitleAtom } from '@/store/metaInfo'
import clsx from 'clsx'
import { toast } from 'react-toastify'
import { useModal } from '@/components/ui/modal'

interface ShareData {
  url: string
  text: string
}

const shareList = [
  {
    name: 'Twitter',
    icon: 'icon-x',
    onClick: (data: ShareData) => {
      window.open(
        `https://twitter.com/intent/tweet?url=${encodeURIComponent(data.url)}&text=${encodeURIComponent(data.text)}&via=${encodeURIComponent(site.title)}`,
      )
    },
  },
  {
    name: '复制链接',
    icon: 'icon-link',
    onClick: (data: ShareData) => {
      navigator.clipboard.writeText(data.url)
      toast.success('已复制到剪贴板')
    },
  },
]

export function ActionAside() {
  return (
    <div
      className="absolute left-0 bottom-0 flex flex-col gap-4"
      style={{
        transform: 'translateY(calc(100% + 24px))',
      }}
    >
      <ShareButton />
      <DonateButton />
    </div>
  )
}

function ShareButton() {
  const postSlug = useAtomValue(metaSlugAtom)
  const postTitle = useAtomValue(metaTitleAtom)
  const { present } = useModal()

  const url = new URL(postSlug, site.url).href
  const text = `嘿,我发现了一片宝藏文章「${postTitle}」哩,快来看看吧!`

  const openModal = () => {
    present({
      content: <ShareModal url={url} text={text} />,
    })
  }

  return (
    <button
      type="button"
      aria-label="Share this post"
      className="size-6 text-xl leading-none hover:text-accent"
      onClick={() => openModal()}
    >
      <i className="iconfont icon-share"></i>
    </button>
  )
}

function ShareModal({ url, text }: { url: string; text: string }) {
  return (
    <motion.div
      className="bg-primary rounded-lg p-2 min-w-[420px] border border-primary flex flex-col"
      initial={{ opacity: 0, scale: 0.8 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, scale: 0.8 }}
    >
      <h2 className="px-3 py-1 font-bold">分享此内容</h2>
      <hr className="my-2 border-primary" />
      <div className="px-3 py-2 grid grid-cols-[180px_auto] gap-3">
        <QR.QRCodeSVG value={url} size={180} />
        <div className="flex flex-col gap-2">
          <div className="text-sm">分享到...</div>
          <ul className="flex flex-col gap-2">
            {shareList.map((item) => (
              <li
                className="px-2 py-1 flex gap-2 cursor-pointer rounded-md hover:bg-secondary"
                key={item.name}
                onClick={() => item.onClick({ url, text })}
                role="button"
                aria-label={`Share to ${item.name}`}
              >
                <i className={clsx('iconfont text-accent', item.icon)}></i>
                <span>{item.name}</span>
              </li>
            ))}
          </ul>
        </div>
      </div>
    </motion.div>
  )
}

function DonateButton() {
  const { present } = useModal()

  const openDonate = () => {
    present({
      content: <DonateContent />,
    })
  }

  return (
    <button
      type="button"
      aria-label="Donate to author"
      className="size-6 text-xl leading-none hover:text-accent"
      onClick={() => openDonate()}
    >
      <i className="iconfont icon-user-heart"></i>
    </button>
  )
}

function DonateContent() {
  return (
    <motion.div
      initial={{ y: 20, opacity: 0 }}
      animate={{ y: 0, opacity: 1 }}
      exit={{ y: 20, opacity: 0 }}
    >
      <h2 className="text-center mb-5">感谢您的支持,这将成为我前进的最大动力。</h2>
      <div className="flex flex-wrap gap-4 justify-center">
        <img
          className="object-cover"
          width={300}
          height={300}
          src={sponsor.wechat}
          alt="微信赞赏码"
          loading="lazy"
          decoding="async"
        />
      </div>
    </motion.div>
  )
}


================================================
FILE: src/components/post/Outdate.tsx
================================================
import { useEffect, useState } from 'react'
import { getDiffInDays, getFormattedDate } from '@/utils/date'
import { motion, AnimatePresence } from 'framer-motion'

export function Outdate({ lastMod }: { lastMod: Date }) {
  const [isShow, setIsShow] = useState(false)

  useEffect(() => {
    const diffDays = getDiffInDays(lastMod)
    if (diffDays > 30) {
      setIsShow(true)
    }
  }, [lastMod])

  return (
    <AnimatePresence>
      {isShow && (
        <motion.div
          className="flex justify-center text-sm p-4 rounded-lg bg-amber-300/10 border border-amber-300"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          <span>
            这篇文章最后修改于 {getFormattedDate(lastMod)}
            ,部分内容可能已经不适用,如有疑问可联系作者。
          </span>
        </motion.div>
      )}
    </AnimatePresence>
  )
}


================================================
FILE: src/components/post/PostArchiveInfo.astro
================================================
---
import { slugify } from '@/utils/content'

interface Props {
  tags: string[]
  category?: string
  class?: string
}

const { tags, category, class: className } = Astro.props
---

<div class="flex flex-wrap gap-2 text-sm text-secondary" class:list={[className]}>
  {
    category && (
      <div>
        <i class="iconfont icon-folder" />
        <a
          class="hover:text-accent hover:underline underline-offset-2 decoration-dashed"
          href={`/categories/${slugify(category)}`}
        >
          {category}
        </a>
      </div>
    )
  }
  {
    tags.length > 0 && (
      <div>
        <i class="iconfont icon-hashtag" />
        {tags.map((tag, index) => (
          <>
            {index > 0 && <span>, </span>}
            <a
              class="hover:text-accent hover:underline underline-offset-2 decoration-dashed"
              href={`/tags/${slugify(tag)}`}
            >
              {tag}
            </a>
          </>
        ))}
      </div>
    )
  }
</div>


================================================
FILE: src/components/post/PostCard.astro
================================================
---
import type { CollectionEntry } from 'astro:content'
import { PostCardHoverOverlay } from './PostCardHoverOverlay'
import PostMetaInfo from './PostMetaInfo.astro'

interface Props {
  entry: CollectionEntry<'posts'>
}

const { entry } = Astro.props

const { remarkPluginFrontmatter } = await entry.render()
---

<a class="block relative py-8 focus-visible:outline-0" href={`/posts/${entry.slug}`}>
  <PostCardHoverOverlay client:only="react" />
  <h2 class="relative text-2xl font-bold">
    {entry.data.title}
    {
      entry.data.sticky > 0 && (
        <i class="absolute right-0 top-0 z-10 size-6 leading-none iconfont icon-pushpin text-red-500" />
      )
    }
  </h2>
  <div class="mt-4 overflow-hidden">
    {
      entry.data.cover && (
        <img
          class="float-right ml-3 mb-2 size-[80px] rounded-md object-cover select-none"
          src={entry.data.cover}
          alt={entry.data.title}
          loading="lazy"
        />
      )
    }
    {entry.data.summary && <p>{entry.data.summary}</p>}
  </div>
  <div class="mt-2 flex flex-wrap items-center justify-end gap-4 select-none">
    <PostMetaInfo
      class="grow"
      date={entry.data.date}
      lastMod={entry.data.lastMod}
      words={remarkPluginFrontmatter.words}
      readingMinutes={remarkPluginFrontmatter.readingMinutes}
    />
    <div class="group shrink-0 text-accent flex items-center gap-2">
      <span>继续阅读</span>
      <svg width="36" height="12" viewBox="0 0 36 12" fill="none" class="stroke-current">
        <path
          d="M0.75 6H11.25 M6 0.75L11.25 6L6 11.25"
          stroke-linecap="round"
          stroke-linejoin="round"></path>
        <path
          d="M15 10L19.5 5.5L15 1"
          stroke-linecap="round"
          stroke-linejoin="round"
          class="delay-100 opacity-0 group-hover:opacity-100"></path>
        <path
          d="M23 10L27.5 5.5L23 1"
          stroke-opacity="0.66"
          stroke-linecap="round"
          stroke-linejoin="round"
          class="delay-200 opacity-0 group-hover:opacity-100"></path>
        <path
          d="M31 10L35.5 5.5L31 1"
          stroke-opacity="0.35"
          stroke-linecap="round"
          stroke-linejoin="round"
          class="delay-300 opacity-0 group-hover:opacity-100"></path>
      </svg>
    </div>
  </div>
</a>


================================================
FILE: src/components/post/PostCardHoverOverlay.tsx
================================================
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'

export function PostCardHoverOverlay() {
  const ref = useRef<HTMLDivElement>(null)
  const [enter, setEnter] = useState(false)

  const handleMouseEnter = () => {
    setEnter(true)
  }
  const handleMouseLeave = () => {
    setEnter(false)
  }
  const handleFocus = () => {
    setEnter(true)
  }
  const handleBlur = () => {
    setEnter(false)
  }

  useEffect(() => {
    const $ref = ref.current
    if (!$ref) return
    const $parent = $ref.parentElement?.parentElement
    if (!$parent) return

    $parent.addEventListener('mouseenter', handleMouseEnter)
    $parent.addEventListener('mouseleave', handleMouseLeave)
    $parent.addEventListener('focus', handleFocus)
    $parent.addEventListener('blur', handleBlur)

    return () => {
      $parent.removeEventListener('mouseenter', handleMouseEnter)
      $parent.removeEventListener('mouseleave', handleMouseLeave)
      $parent.removeEventListener('focus', handleFocus)
      $parent.removeEventListener('blur', handleBlur)
    }
  }, [])

  return (
    <>
      <div ref={ref} className="hidden"></div>
      <AnimatePresence>
        {enter && (
          <motion.div
            className="absolute inset-y-4 -inset-x-4 -z-1 bg-accent/10 rounded-lg"
            initial={{ opacity: 0.2, scale: 0.95 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.95 }}
            layout
            layoutId="post-card-hover-overlay"
          ></motion.div>
        )}
      </AnimatePresence>
    </>
  )
}


================================================
FILE: src/components/post/PostCopyright.tsx
================================================
import { author, site } from '@/config.json'
import { getFormattedDateTime } from '@/utils/date'
import { AnimatedSignature } from '../AnimatedSignature'
import { useEffect, useState } from 'react'
import { toast } from "react-toastify";

function getPostUrl(slug: string) {
  return new URL(slug, site.url).href
}

export function PostCopyright({
  title,
  slug,
  lastMod,
}: {
  title: string
  slug: string
  lastMod: Date
}) {
  const [lastModStr, setLastModStr] = useState('')
  const url = getPostUrl(slug)

  function handleCopyUrl() {
    navigator.clipboard.writeText(url)
    toast.success('已复制文章链接')
  }

  useEffect(() => {
    setLastModStr(getFormattedDateTime(lastMod))
  }, [lastMod])

  return (
    <section className="text-xs leading-loose text-secondary">
      <p>文章标题:{title}</p>
      <p>文章作者:{author.name}</p>
      <p>
        <span>文章链接:{url}</span>
        <span role="button" className="cursor-pointer select-none" onClick={handleCopyUrl}>
          [复制]
        </span>
      </p>
      <p>最后修改时间:{lastModStr}</p>
      <hr className="my-3 border-primary" />
      <div>
        <div className="float-right ml-4 my-2">
          <AnimatedSignature />
        </div>
        <p>
          商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
          <br />
          本文采用
          <a
            className="hover:underline hover:text-accent underline-offset-2"
            href="https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh"
            target="_blank"
            rel="noopener noreferrer"
          >
            CC BY-NC-SA 4.0
          </a>
          进行许可。
        </p>
      </div>
    </section>
  )
}


================================================
FILE: src/components/post/PostList.astro
================================================
---
import type { CollectionEntry } from 'astro:content'
import PostCard from './PostCard.astro'

interface Props {
  posts: CollectionEntry<'posts'>[]
}

const { posts } = Astro.props
---

<div>
  <ul class="-my-4">
    {
      posts.map((post) => (
        <li>
          <PostCard entry={post} />
        </li>
      ))
    }
  </ul>
</div>


================================================
FILE: src/components/post/PostMetaInfo.astro
================================================
---
import { RelativeDate } from './RelativeDate'

interface Props {
  date: Date
  lastMod?: Date
  words: number
  readingMinutes: number
  class?: string
}

const { date, lastMod, words, readingMinutes, class: className } = Astro.props
---

<div class="flex flex-wrap gap-2 text-sm text-secondary" class:list={[className]}>
  <div>
    <i class="iconfont icon-calendar"></i>
    <RelativeDate date={date} client:idle />
    {lastMod && <span class="text-xs">(已编辑)</span>}
  </div>
  <div>
    <i class="iconfont icon-file-list"></i>
    <span>{words} 字</span>
  </div>
  <div>
    <i class="iconfont icon-timer"></i>
    <span>{Math.ceil(readingMinutes)} 分钟</span>
  </div>
</div>


================================================
FILE: src/components/post/PostNav.astro
================================================
---
import type { CollectionEntry } from 'astro:content'

interface Props {
  prev?: CollectionEntry<'posts'>
  next?: CollectionEntry<'posts'>
  class?: string
}

const { prev, next, class: className } = Astro.props
---

<div class="grid grid-cols-2" class:list={[className]}>
  <div>
    {
      prev && (
        <a class="flex items-baseline gap-2 hover:text-accent" href={`/posts/${prev.slug}`}>
          <i class="iconfont icon-arrow-left" />
          <span>{prev.data.title}</span>
        </a>
      )
    }
  </div>
  <div>
    {
      next && (
        <a
          class="flex items-baseline justify-end gap-2 hover:text-accent"
          href={`/posts/${next.slug}`}
        >
          <span>{next.data.title}</span>
          <i class="iconfont icon-arrow-right" />
        </a>
      )
    }
  </div>
</div>


================================================
FILE: src/components/post/PostPagination.astro
================================================
---
interface Props {
  current: number
  total: number
  getPageUrl: (page: number) => string
}

const { current, total, getPageUrl } = Astro.props

const items = []
if (current > 1) items.push({ isButton: true, page: current - 1, icon: 'left', label: '上一页' })
if (current > 1) items.push({ isButton: true, page: 1 })
if (current > 3) items.push({ isButton: false, icon: 'more' })
if (current > 2) items.push({ isButton: true, page: current - 1 })
items.push({ isButton: false, page: current })
if (current < total - 1) items.push({ isButton: true, page: current + 1 })
if (current < total - 2) items.push({ isButton: false, icon: 'more' })
if (current < total) items.push({ isButton: true, page: total })
if (current < total)
  items.push({ isButton: true, page: current + 1, icon: 'right', label: '下一页' })
---

<div class="mt-12 flex items-center justify-center gap-2">
  {
    items.map((item) => {
      if (item.isButton) {
        return (
          <a
            class="size-8 rounded-lg flex items-center justify-center transition-bg-color duration-200  hover:bg-accent/20 select-none"
            href={getPageUrl(item.page!)}
            aria-label={item.label}
          >
            {item.icon ? <i class={`iconfont icon-${item.icon}`} /> : item.page}
          </a>
        )
      } else {
        return (
          <span
            class="size-8 rounded-lg flex items-center justify-center cursor-default select-none"
            class:list={[{ 'text-white bg-accent dark:text-black': item.page === current }]}
          >
            {item.icon ? <i class={`iconfont icon-${item.icon}`} /> : item.page}
          </span>
        )
      }
    })
  }
</div>


================================================
FILE: src/components/post/PostToc.tsx
================================================
import { pageScrollLocationAtom, pageScrollDirectionAtom } from '@/store/scrollInfo'
import type { MarkdownHeading } from 'astro'
import clsx from 'clsx'
import { useAtomValue } from 'jotai'
import { startTransition, useEffect, useRef, useState } from 'react'

function useActiveItem() {
  const [activeItem, setActiveItem] = useState('')
  const scrollY = useAtomValue(pageScrollLocationAtom)

  useEffect(() => {
    const $article = document.querySelector('#markdown-wrapper')
    if (!$article) return
    const $headings = Array.from($article.querySelectorAll('h1,h2,h3,h4,h5,h6'))
    for (let i = 0; i < $headings.length; i++) {
      const item = $headings[i]
      const nextItem = $headings[i + 1]
      const itemTop = item.getBoundingClientRect().top
      const nextItemTop = nextItem ? nextItem.getBoundingClientRect().top : 10000

      if (itemTop <= 80 && nextItemTop > 80) {
        startTransition(() => {
          setActiveItem(item.id)
        })
        break
      }
    }
  }, [scrollY])

  return activeItem
}

export function PostToc({ headings }: { headings: MarkdownHeading[] }) {
  const activeItem = useActiveItem()

  return (
    <ul
      className="relative overflow-y-auto space-y-2 group text-sm"
      style={{
        maxHeight: 'min(380px, calc(100vh - 250px))',
        scrollbarWidth: 'none',
      }}
    >
      {headings.map((item) => (
        <TocItem
          key={item.slug}
          slug={item.slug}
          text={item.text}
          depth={item.depth}
          isActive={item.slug === activeItem}
        />
      ))}
    </ul>
  )
}

export function TocItem({
  slug,
  text,
  depth,
  isActive,
}: {
  slug: string
  text: string
  depth: number
  isActive: boolean
}) {
  const itemRef = useRef<HTMLLIElement>(null)
  const scrollDirection = useAtomValue(pageScrollDirectionAtom)

  useEffect(() => {
    if (!isActive) return
    const $item = itemRef.current
    if (!$item) return
    const $container = $item.parentElement
    if (!$container) return

    const containerHeight = $container.clientHeight
    const itemHeight = $item.clientHeight
    const itemOffsetTop = $item.offsetTop
    const scrollTop = $container.scrollTop

    const itemTop = itemOffsetTop - scrollTop
    const itemBottom = itemTop + itemHeight

    if (itemTop < 0 || itemBottom > containerHeight) {
      if (scrollDirection === 'up') {
        $container.scrollTop = itemOffsetTop - containerHeight + itemHeight

      } else {
        $container.scrollTop = itemOffsetTop
      }
    }
  }, [isActive])

  return (
    <li className="relative" ref={itemRef}>
      <span
        className={clsx(
          'absolute left-0 top-2 h-1 rounded-full',
          isActive ? 'bg-accent' : 'bg-zinc-300 dark:bg-zinc-700',
        )}
        style={{ width: `${4 * (7 - depth)}px` }}
      ></span>
      <a
        className={clsx(
          'inline-block pl-8 opacity-0 transition-opacity duration-300',
          isActive ? 'opacity-100' : 'group-hover:opacity-100 text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100',
        )}
        href={`#${slug}`}
      >
        <span>{text}</span>
      </a>
    </li>
  )
}


================================================
FILE: src/components/post/ReadingProgress.tsx
================================================
import { useEffect, useState } from 'react'
import { useAtomValue } from 'jotai'
import { pageScrollLocationAtom } from '@/store/scrollInfo'
import { floor } from 'lodash-es'

export function ReadingProgress() {
  const [percent, setPercent] = useState(0)
  const scrollY = useAtomValue(pageScrollLocationAtom)

  useEffect(() => {
    const $article = document.querySelector('#markdown-wrapper')
    if (!$article) return

    const { offsetHeight, offsetTop } = $article as HTMLElement
    const fullHeight = offsetHeight + offsetTop - window.innerHeight

    if (scrollY > fullHeight) {
      setPercent(100)
    } else {
      setPercent(floor((scrollY / fullHeight) * 100))
    }
  }, [scrollY])

  return (
    <div>
      <span className="text-sm">进度 {percent}%</span>
    </div>
  )
}


================================================
FILE: src/components/post/RelativeDate.tsx
================================================
import { getRelativeTime, getFormattedDate } from '@/utils/date'
import { useEffect, useState } from 'react'

export function RelativeDate({ date }: { date: Date }) {
  const [dateStr, setDateStr] = useState(getFormattedDate(date))

  useEffect(() => {
    const relative = getRelativeTime(date)
    if (relative) {
      setDateStr(relative)
    }
  }, [date])

  return <span>{dateStr}</span>
}


================================================
FILE: src/components/provider/HeaderMetaInfoProvider.tsx
================================================
import { useSetAtom } from 'jotai'
import { useEffect } from 'react'
import { pathNameAtom, metaTitleAtom, metaDescriptionAtom, metaSlugAtom } from '@/store/metaInfo'

export function HeaderMetaInfoProvider({
  pathName,
  title = '',
  description = '',
  slug = '',
}: {
  pathName: string
  title?: string
  description?: string
  slug?: string
}) {
  const setPathName = useSetAtom(pathNameAtom)
  const setTitle = useSetAtom(metaTitleAtom)
  const setDescription = useSetAtom(metaDescriptionAtom)
  const setSlug = useSetAtom(metaSlugAtom)

  useEffect(() => {
    // 去掉 pathName 结尾的 '/'
    if (pathName !== '/') {
      setPathName(pathName.replace(/\/$/, ''))
    } else {
      setPathName(pathName)
    }
    setTitle(title)
    setDescription(description)
    setSlug(slug)
  }, [pathName, title, description, slug])

  return null
}


================================================
FILE: src/components/provider/PageScrollInfoProvider.tsx
================================================
import { useLayoutEffect, useRef } from 'react'
import { throttle } from 'lodash-es'
import { useSetAtom } from 'jotai'
import { pageScrollLocationAtom, pageScrollDirectionAtom } from '@/store/scrollInfo'

export function PageScrollInfoProvider() {
  const setScrollLocation = useSetAtom(pageScrollLocationAtom)
  const setScrollDirection = useSetAtom(pageScrollDirectionAtom)
  const prevScrollY = useRef(0)

  const scrollHandler = throttle(
    () => {
      let currentTop = document.documentElement.scrollTop

      if (currentTop === 0) {
        const bodyStyle = document.body.style
        if (bodyStyle.position === 'fixed') {
          const bodyTop = bodyStyle.top
          currentTop = Math.abs(parseInt(bodyTop, 10))
        }
      }

      setScrollDirection(prevScrollY.current - currentTop > 0 ? 'up' : 'down')
      prevScrollY.current = currentTop
      setScrollLocation(currentTop)
    },
    16,
    {
      leading: false,
    },
  )

  useLayoutEffect(() => {
    scrollHandler()
    window.addEventListener('scroll', scrollHandler)
    return () => {
      window.removeEventListener('scroll', scrollHandler)
    }
  }, [])
  return null
}


================================================
FILE: src/components/provider/Provider.tsx
================================================
import { HeaderMetaInfoProvider } from './HeaderMetaInfoProvider'
import { PageScrollInfoProvider } from './PageScrollInfoProvider'
import { ThemeProvider } from './ThemeProvider'
import { ViewportProvider } from './ViewportProvider'

export function Provider(props: {
  pathName: string
  title?: string
  description?: string
  slug?: string
}) {
  return (
    <>
      <HeaderMetaInfoProvider {...props} />
      <PageScrollInfoProvider />
      <ThemeProvider />
      <ViewportProvider />
    </>
  )
}


================================================
FILE: src/components/provider/ThemeProvider.tsx
================================================
import { useAtomValue } from 'jotai'
import { useEffect } from 'react'
import { getSystemTheme, changePageTheme, setLocalTheme } from '@/utils/theme'
import { themeAtom } from '@/store/theme'

export function ThemeProvider() {
  const theme = useAtomValue(themeAtom)

  function handlePrefersColorSchemeChange(event: MediaQueryListEvent) {
    if (theme === 'system') {
      changePageTheme(event.matches ? 'dark' : 'light')
    }
  }

  useEffect(() => {
    setLocalTheme(theme)

    if (theme === 'system') {
      const systemTheme = getSystemTheme()
      changePageTheme(systemTheme)
    } else {
      changePageTheme(theme)
    }

    const query = window.matchMedia('(prefers-color-scheme: dark)')
    query.addEventListener('change', handlePrefersColorSchemeChange)

    return () => {
      query.removeEventListener('change', handlePrefersColorSchemeChange)
    }
  }, [theme])

  return null
}


================================================
FILE: src/components/provider/ViewportProvider.tsx
================================================
import { useSetAtom } from 'jotai'
import { useEffect } from 'react'
import { isMobileAtom } from '@/store/viewport'

export function ViewportProvider() {
  const setIsMobile = useSetAtom(isMobileAtom)

  const handleResize = (event: MediaQueryListEvent) => {
    setIsMobile(!event.matches)
  }

  useEffect(() => {
    const query = window.matchMedia('(min-width: 768px)')
    setIsMobile(!query.matches)
    query.addEventListener('change', handleResize)
    return () => {
      query.removeEventListener('change', handleResize)
    }
  }, [])

  return null
}


================================================
FILE: src/components/ui/modal/Modal.tsx
================================================
import { modalStackAtom } from '@/store/modalStack'
import { useSetAtom } from 'jotai'
import * as Dialog from '@radix-ui/react-dialog'
import { motion } from 'framer-motion'
import { CurrentModalContext } from './context'

export function Modal({
  children,
  index,
  id,
}: {
  index: number
  children: React.ReactNode
  id: string
}) {
  const baseZIndex = 1000
  const overlayZIndex = baseZIndex + index
  const contentZIndex = baseZIndex + index + 1
  const setModalStack = useSetAtom(modalStackAtom)

  const close = () => {
    setModalStack((stack) => stack.filter((modal) => modal.id !== id))
  }

  return (
    <Dialog.Root
      open
      onOpenChange={(isOpen) => {
        if (!isOpen) {
          close()
        }
      }}
    >
      <Dialog.Portal>
        <Dialog.Overlay asChild>
          <motion.div
            className="fixed inset-0 bg-gray-800/40"
            style={{ zIndex: overlayZIndex }}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { delay: 0.1 } }}
          ></motion.div>
        </Dialog.Overlay>

        <Dialog.Content
          className="fixed inset-0 flex items-center justify-center"
          style={{ zIndex: contentZIndex }}
          onClick={(e) => {
            if (e.target === e.currentTarget) {
              close()
            }
          }}
        >
          <CurrentModalContext.Provider value={{ dismiss: close }}>
            {children}
          </CurrentModalContext.Provider>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}


================================================
FILE: src/components/ui/modal/ModalStack.tsx
================================================
import { useAtomValue } from 'jotai'
import { Modal } from './Modal'
import { modalStackAtom } from '@/store/modalStack'
import { AnimatePresence } from 'framer-motion'

export function ModalStack() {
  const modalStack = useAtomValue(modalStackAtom)

  return (
    <AnimatePresence>
      {modalStack.map((modal, index) => (
        <Modal key={modal.id} index={index} id={modal.id}>
          {modal.content}
        </Modal>
      ))}
    </AnimatePresence>
  )
}


================================================
FILE: src/components/ui/modal/context.ts
================================================
import { createContext } from 'react'

export const CurrentModalContext = createContext<{
  dismiss: () => void
}>(null as any)


================================================
FILE: src/components/ui/modal/hooks.ts
================================================
import { useContext, useId, useRef } from 'react'
import { useSetAtom } from 'jotai'
import { modalStackAtom } from '@/store/modalStack'
import { CurrentModalContext } from './context'

type ModalProps = {
  id?: string
  content: React.ReactNode
}

export function useModal() {
  const id = useId()
  const currentCount = useRef(0)
  const setModalStack = useSetAtom(modalStackAtom)

  return {
    present(props: ModalProps) {
      const modalId = `${id}-${currentCount.current++}`
      const modalProps = {
        ...props,
        id: props.id ?? modalId,
      }
      setModalStack((stack) => [...stack, modalProps])
      return () => {
        setModalStack((stack) => stack.filter((modal) => modal.id !== modalProps.id))
      }
    },
  }
}

export function useCurrentModal() {
  return useContext(CurrentModalContext)
}


================================================
FILE: src/components/ui/modal/index.ts
================================================
export * from './hooks'
export * from './ModalStack'


================================================
FILE: src/config.json
================================================
{
  "site": {
    "url": "https://gyoza.lxchapu.com",
    "title": "Gyoza",
    "description": "这是一个使用 Astro 和 React 开发的博客主题。",
    "keywords": "Gyoza,blog,Astro,theme,lxchapu,博客主题",
    "lang": "zh-CN",
    "favicon": "/favicon.ico",
    "appleTouchIcon": "/apple-touch-icon.png"
  },
  "author": {
    "name": "lxchapu",
    "twitterId": "@lxchapu",
    "avatar": "https://s2.loli.net/2024/04/30/ozsnuS5Ihf3xMBG.webp"
  },
  "hero": {
    "name": "Gyoza",
    "bio": "A static blog template build with Astro and React.",
    "description": "Clean, Cute, Fast.",
    "socials": [
      {
        "name": "Github",
        "icon": "icon-github",
        "url": "https://github.com/lxchapu/astro-gyoza",
        "color": "rgb(24, 23, 23)"
      },
      {
        "name": "X",
        "icon": "icon-x",
        "url": "https://twitter.com/lxchapu",
        "color": "rgb(36, 46, 54)"
      },
      {
        "name": "Email",
        "icon": "icon-mail",
        "url": "mailto:lxchapu@outlook.com",
        "color": "rgb(212, 70, 56)"
      }
    ],
    "yiyan": "当第一颗卫星飞向大气层外,我们便以为自己终有一日会征服宇宙。"
  },
  "color": {
    "accent": [
      { "light": "#F55555", "dark": "#FCCF31" },
      { "light": "#0396FF", "dark": "#ABDCFF" },
      { "light": "#fb7287", "dark": "#99D8CF" },
      { "light": "#F072B6", "dark": "#FFF886" },
      { "light": "#9F44D3", "dark": "#E2B0FF" },
      { "light": "#FF6666", "dark": "#A1CCD1" },
      { "light": "#F6416C", "dark": "#838BC6" },
      { "light": "#32CCBC", "dark": "#90F7EC" },
      { "light": "#33A6B8", "dark": "#79F1A4" },
      { "light": "#F55555", "dark": "#FCCF31" }
    ],
    "bg": {
      "primary": { "light": "#ffffff", "dark": "#1c1c1e" },
      "secondary": { "light": "#f4f4f5", "dark": "#27272a" }
    },
    "text": {
      "primary": { "light": "#373a3c", "dark": "#ffffff" },
      "secondary": { "light": "#71717a", "dark": "#d1d5db" }
    },
    "border": {
      "primary": { "light": "#e4e4e7", "dark": "#3f3f46" }
    }
  },
  "menus": [
    {
      "name": "首页",
      "link": "/",
      "icon": "icon-pantone"
    },
    {
      "name": "归档",
      "link": "/archives",
      "icon": "icon-archive"
    },
    {
      "name": "项目",
      "link": "/projects",
      "icon": "icon-flask"
    },
    {
      "name": "关于",
      "link": "/about",
      "icon": "icon-ghost"
    },
    {
      "name": "友链",
      "link": "/friends",
      "icon": "icon-hearts"
    }
  ],
  "posts": {
    "perPage": 10
  },
  "footer": {
    "startTime": "2024-04-14T00:00:00Z"
  },
  "waline": {
    "serverURL": "https://waline.lxchapu.com"
  },
  "sponsor": {
    "wechat": "https://object.lxchapu.com/bed%2F2024%2F0507_6e3e8f73df2d4e6d.webp"
  },
  "analytics": {
    "enable": false,
    "google": {
      "measurementId": ""
    },
    "umami": {
      "serverUrl": "",
      "websiteId": ""
    },
    "microsoftClarity": {
      "projectId": ""
    }
  }
}


================================================
FILE: src/content/config.ts
================================================
import { z, defineCollection } from 'astro:content'

const postsCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    date: z.date(),
    lastMod: z.date().optional(),
    summary: z.string().optional(),
    cover: z.string().optional(),
    category: z.string().optional(),
    tags: z.array(z.string()).default([]),
    comments: z.boolean().default(true),
    draft: z.boolean().default(false),
    sticky: z.number().default(0),
  }),
})

const projectsCollection = defineCollection({
  type: 'data',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    image: z.string(),
    link: z.string().url(),
  }),
})

const specCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    comments: z.boolean().default(true),
  }),
})

const friendsCollection = defineCollection({
  type: 'data',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    avatar: z.string(),
    link: z.string().url(),
  }),
})

export const collections = {
  posts: postsCollection,
  projects: projectsCollection,
  spec: specCollection,
  friends: friendsCollection,
}


================================================
FILE: src/content/friends/Keigo.yml
================================================
title: Keigo
description: 那天早上的霧散了,不止早上,不止霧
link: https://astro.sliverkeigo.top/
avatar: https://www.sliverkeigo.top/_next/image?url=https%3A%2F%2Fsliverkeigo.top%2Fapi%2Fv2%2Fobjects%2Favatar%2Fd7mox619mtisq9vtxt.png&w=384&q=75


================================================
FILE: src/content/friends/astro-docs.yaml
================================================
title: Astro Docs
description: Astro 入门指南
link: https://docs.astro.build/en/getting-started/
avatar: https://s2.loli.net/2023/12/13/YbKirkO21CtdvMD.png


================================================
FILE: src/content/friends/lxchapu.yaml
================================================
title: '柃夏chapu'
description: '生活明朗,万物可爱。'
link: 'https://www.lxchapu.com'
avatar: 'https://s2.loli.net/2024/04/23/tIxXmT45RbBDWH8.webp'


================================================
FILE: src/content/posts/embed.md
================================================
---
title: 在文章中嵌入视频和代码
date: 2024-04-04
lastMod: 2024-05-18T07:29:49.820Z
tags: [Video, Markdown]
category: 例子
summary: 这篇文章介绍了如何在文章中嵌入视频和代码。
---

## Codepen

```md
::codepen{#gOyLepE author="lxchapu"}
```

::codepen{#gOyLepE author="lxchapu"}

## YouTube

```md
::youtube{#BuKft9LpL_0}
```

::youtube{#BuKft9LpL_0}

## Bilibili

```md
::bilibili{#BV1Mx4y1Y7pJ}
```

::bilibili{#BV1Mx4y1Y7pJ}


================================================
FILE: src/content/posts/guide.md
================================================
---
title: Gyoza 使用指南
date: 2024-04-01
lastMod: 2024-08-10T03:58:16.758Z
summary: 欢迎使用 Gyoza,Gyoza 是一款 Astro 博客主题,它保持简洁和可爱的风格。本篇文章将会介绍如何使用并部署 Gyoza。
category: 教程
tags: [Astro, Gyoza]
sticky: 1
---

## 前置条件

- node 版本 >= 18.18.0
- pnpm 版本 > 8.1.0

## 安装

### 克隆仓库

登录 Github 账号,打开 [lxchapu/astro-gyoza](https://github.com/lxchapu/astro-gyoza),点击右上角的 Fork 按钮,将仓库克隆到你自己的账号下。

复制这个仓库的地址,打开终端,使用 `git clone` 命令将仓库克隆到本地。

> 本项目推荐使用 pnpm 作为你的包管理器,如果你还没有安装 pnpm,请先安装 pnpm。

### 安装依赖

```sh
cd astro-gyoza
pnpm install
```

### 命令介绍

本地运行

```sh
pnpm dev
```

打包静态文件

```sh
pnpm build
```

本地预览

```sh
pnpm preview
```

### 配置项

本项目中的绝大部分配置都定义在 `src/config.json` 文件中。

你应该首先将 `site.url` 修改成自己的域名,避免导航错误。

以下是配置项的说明:

```json
{
  "site": {
    "url": "", // 网站地址
    "title": "", // 网站标题
    "description": "", // 通用的网站描述 SEO
    "keywords": "", // 通用的网站关键词 SEO
    "lang": "zh-CN", // 网站的语言
    "favicon": "", // 浏览器图标,存放在 public 目录下
    "appleTouchIcon": "" // 苹果设备图标,存放在 public 目录下
  },
  "author": {
    "name": "", // 作者名称
    "twitterId": "", // 推特账号 ID,以 @ 开头,用于 Open Graph
    "avatar": "" // 作者头像地址
  },
  // 首页 Hero 组件
  "hero": {
    "name": "", // 显示的名称
    "bio": "", // 一句话介绍
    "description": "", // 补充描述
    // 社交账号
    "socials": [
      {
        "name": "", // 社交平台类型
        "icon": "", // 社交平台图标
        "url": "", // 链接
        "color": "" // 图标颜色
      }
    ],
    "yiyan": "" // 显示一言
  },
  "color": {
    // 强调色,请填写 16 进制颜色值。每次会从中随机取出一组
    "accent": [{ "light": "", "dark": "" }],
    // 背景色
    "bg": {
      "primary": { "light": "", "dark": "" },
      "secondary": { "light": "", "dark": "" }
    },
    // 文字颜色
    "text": {
      "primary": { "light": "", "dark": "" },
      "secondary": { "light": "", "dark": "" }
    },
    // 边框颜色
    "border": {
      "primary": { "light": "", "dark": "" }
    }
  },
  // 顶部导航栏
  "menus": [
    {
      "name": "首页",
      "link": "/",
      "icon": "icon-pantone"
    }
  ],
  "posts": {
    "perPage": 10 // 每一页显示的文章数量
  },
  "footer": {
    "startTime": "" // 博客网站开始时间 请使用 ISO 格式
  },
  // Waline 评论系统,前往 https://waline.js.org/ 查看
  "waline": {
    "serverURL": ""
  },
  // 赞助
  "sponsor": {
    "wechat": "" // 微信赞赏码图片地址
  },
  // 如果需要使用网站数据统计,将 enable 修改为 true,并填写对应的配置
  "analytics": {
    "enable": false,
    // https://analytics.google.com
    "google": {
      "measurementId": ""
    },
    // https://umami.is/docs
    "umami": {
      "serverUrl": "",
      "websiteId": ""
    },
    // https://clarity.microsoft.com/
    "microsoftClarity": {
      "projectId": ""
    }
  }
}
```

## 部署

> 这里只介绍了 Vercel,你当然可以选择其他平台例如:Cloudflare Pages 或你自己的服务器。  
> 部署之前,确保你已经修改 `site.url`。

### 部署到 Vercel

登录 Vercel 账号,点击右上角的 Add new... 选择 Project。然后在 Import Git Repository 中选择刚刚 Fork 的仓库,点击 Import 按钮。

进入项目配置页面,直接点击 Deploy 按钮,静静等待部署完成就 👌 了。

Vercel 会为你分配一个域名,你可以在项目设置中设置自定义域名,更多操作请参考 Vercel 文档。


================================================
FILE: src/content/posts/how-to-use-icons.md
================================================
---
title: 如何在 Gyoza 中使用图标?
date: 2024-05-08T10:54:27.000Z
tags: [Icon]
category: 教程
comments: true
draft: false
---

Gyoza 选择 font-class 的方式引用图标。这些图标大部分来源于 [Remix Icons](https://remixicon.com/),并且在 [iconfont](https://www.iconfont.cn/) 上进行管理和导出。

下图展示了项目中的所有图标:

![所有图标](https://s2.loli.net/2024/05/08/mbdT5HqYMEajyRG.webp)

当你在添加首页显示的社交账号时,你可能会想要使用这些图标。在对应的配置项中填写图标下面有 `icon-` 前缀的名称即可。

如果是在组件中使用图标,可以按照如下方式:

```jsx
<i className="iconfont icon-xxx"></i>
```

## 为什么不是 SVG 图标?

你可能看到很多的项目在使用 [iconify](https://iconify.design/)。iconify 是一个开源图标集,包含超过 20 万个图标,提供了多种框架的引入方式。Astro 中也有对应的插件 astro-icon 可以使用(如果对此感兴趣,可以查看他们的[文档](https://github.com/natemoo-re/astro-icon))。

我在项目中也尝试使用过 iconify,但是出于以下几个原因,我最终还是转向了 font-class 的方式:

- 由于项目中同时使用了 Astro 和 React,而在 Astro 组件和 React 组件中使用 iconify 图标的方式是不同的,这会导致代码中不得不存在两种使用方式。
- iconify 在加载时需要请求它的服务器,~~我会担心请求失败~~,虽然这种担心是多余的。
- 有一个功能是我会在渲染文章时往 markdown 中注入一些图标,例如外部链接尾部的图标,iconify 想要做到这一点并不方便。
- 在 HTML 中直接嵌入 SVG icon 的方式并不优雅,使用 font-class 只需要对应的类名,感觉相较而言最终的 HTML 体积小一点,页面加载会快点。我还没有做过具体的测试,但是至少我会尽量避免页面中出现大量的 SVG 仅仅只是作为图标使用。
- 该项目中用到的图标并不多,主要是一些常用的社交账号的图标,供自定义联系方式时使用。我希望所有图标集中在一起管理,这样更方便一点。

我必须要承认,目前的图标方案并不优雅,每当图标集合发生修改时我都需要更新对应的字体文件和 CSS 文件。而且其他人想要管理图标集合也变得困难。

也许我会在未来尝试其他方式,例如 [@iconify/tailwind](https://github.com/iconify/iconify/tree/main/plugins/tailwind),如果你有更好的方案,也欢迎给我留言。

## 自定义图标

如果你想要替换 iconfont 的图标,请修改以下文件:

```text
public/fonts/iconfont.ttf
public/fonts/iconfont.woff
public/fonts/iconfont.woff2
src/styles/iconfont.css
```

注意,这将会替换掉项目中使用的所有图标,所以请确保你知道自己在做什么。


================================================
FILE: src/content/posts/markdown.md
================================================
---
title: Markdown 示例
date: 2024-04-01
summary: 这是一篇 Markdown 文章的示例。展示了 Markdown 的语法和渲染效果。
category: 例子
tags: [Markdown]
---

下面是在 Astro 中编写 Markdown 内容时,可以使用的一些基本 Markdown 语法示例。

## 标题

你应该避免在 Markdown 正文中重复创建文章标题,因为文章标题会根据 `frontmatter` 中 `title` 自动生成。

> 避免标题层级过深,一般到三级标题就够了。

# 一级

## 二级

### 三级 `inline code`

#### 四级

##### 五级

###### 六级

## 段落

Xerum, quo qui aut unt expliquam qui dolut labo. Aque venitatiusda cum, voluptionse latur sitiae dolessi aut parist aut dollo enim qui voluptate ma dolestendit peritin re plis aut quas inctum laceat est volestemque commosa as cus endigna tectur, offic to cor sequas etum rerum idem sintibus eiur? Quianimin porecus evelectur, cum que nis nust voloribus ratem aut omnimi, sitatur? Quiatem. Nam, omnis sum am facea corem alique molestrunt et eos evelece arcillit ut aut eos eos nus, sin conecerem erum fuga. Ri oditatquam, ad quibus unda veliamenimin cusam et facea ipsamus es exerum sitate dolores editium rerore eost, temped molorro ratiae volorro te reribus dolorer sperchicium faceata tiustia prat.

使用行尾使用两个空格进行段落内的换行

All work and no play makes Jack a dull boy.  
All work and no play makes Jack a dull boy.

## 图片

小尺寸的图片

![图片描述](https://picsum.photos/seed/picsum/250/400)

大尺寸的图片

![图片描述](https://picsum.photos/seed/picsum/1200/900)

带标题的图片

![图片描述](https://picsum.photos/seed/picsum/400/300 '图片标题')

## 强调

这是**重要内容**,这是*次要内容*

## 删除线

~~这是一段被删除的文本。~~

## 引用

The blockquote element represents content that is quoted from another source, optionally with a citation which must be within a `footer` or `cite` element, and optionally with in-line changes such as annotations and abbreviations.

> Tiam, ad mint andaepu dandae nostion secatur sequo quae.  
> **Note** that you can use _Markdown syntax_ within a blockquote.

嵌套的引用

> 引用
>
> > 嵌套的引用

带脚标的引用

> Don't communicate by sharing memory, share memory by communicating.<br>
> — <cite>Rob Pike[^1]</cite>

[^1]: The above quote is excerpted from Rob Pike's [talk](https://www.youtube.com/watch?v=PAAkCSZUG1c) during Gopherfest, November 18, 2015.

## 分割线

---

## 链接

这是内部链接 [Gyoza 使用指南](/posts/guide)

这是外部连接 [React **中文**文档](https://zh-hans.react.dev/)

自动渲染成连接 <https://github.com>

邮箱地址 <mail@example.com>

## 表格

设置单元格对齐

| Name  | Age |  Fruit |
| :---- | :-: | -----: |
| Bob   | 27  |  Apple |
| Alice | 23  | Banana |
| John  | 28  | Orange |

支持行内 Markdown

| Italics   | Bold     | Code   |
| --------- | -------- | ------ |
| _italics_ | **bold** | `code` |

表格溢出

| A                                                        | B                                                                                                             | C                                                                                                                                    | D                                                 | E                                                          | F                                                                    |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------- |
| Lorem ipsum dolor sit amet, consectetur adipiscing elit. | Phasellus ultricies, sapien non euismod aliquam, dui ligula tincidunt odio, at accumsan nulla sapien eget ex. | Proin eleifend dictum ipsum, non euismod ipsum pulvinar et. Vivamus sollicitudin, quam in pulvinar aliquam, metus elit pretium purus | Proin sit amet velit nec enim imperdiet vehicula. | Ut bibendum vestibulum quam, eu egestas turpis gravida nec | Sed scelerisque nec turpis vel viverra. Vivamus vitae pretium sapien |

## 代码块

### Syntax

we can use 3 backticks ``` in new line and write snippet and close with 3 backticks on new line and to highlight language specific syntac, write one word of language name after first 3 backticks, for eg. html, javascript, css, markdown, typescript, txt, bash

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Example HTML5 Document</title>
  </head>
  <body>
    <p>Test</p>
  </body>
</html>
```

```
const var text = "hello world"
```

## KaTeX 公式

使用 `$` 符号包裹公式生成行内公式,例如:$E = mc^2$。

使用 `$$` 符号包裹公式来生成独立公式。例如:

$$
e^{i\pi} + 1 = 0
$$

也可以使用代码块(` ```math `)的方式:

```math
\oint_{\partial V} \mathbf{E} \cdot d\mathbf{A} = \frac{Q}{\epsilon_0}
```

## List Types

### Ordered List

#### Syntax

```markdown
1. First item
2. Second item
3. Third item
```

#### Output

1. First item
2. Second item
3. Third item

### Unordered List

#### Syntax

```markdown
- List item
- Another item
- And another item
```

#### Output

- List item
- Another item
- And another item

### Nested list

#### Syntax

```markdown
- Fruit
  - Apple
  - Orange
  - Banana
- Dairy
  - Milk
  - Cheese
```

#### Output

- Fruit
  - Apple
  - Orange
  - Banana
- Dairy
  - Milk
  - Cheese

## Other Elements — abbr, sub, sup, kbd, mark

### Syntax

```markdown
<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.

H<sub>2</sub>O

X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>

Press <kbd><kbd>CTRL</kbd>+<kbd>ALT</kbd>+<kbd>Delete</kbd></kbd> to end the session.

Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.
```

#### Output

<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.

H<sub>2</sub>O

X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>

Press <kbd><kbd>CTRL</kbd>+<kbd>ALT</kbd>+<kbd>Delete</kbd></kbd> to end the session.

Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.

## Spoiler

```md
||hide content||
```

正常情况下,该内容会隐藏 ||hide content||,鼠标悬浮时才会显示。


================================================
FILE: src/content/projects/gyoza.yaml
================================================
title: Gyoza
description: Gyoza 是一个使用 Astro 和 React 开发的响应式博客主题
image: https://s2.loli.net/2024/03/02/hba5MAilRXguZtr.webp
link: https://github.com/lxchapu/astro-gyoza


================================================
FILE: src/content/spec/about.md
================================================
---
title: 自述
description: 这是一份站长的自述报告,请查收。
comments: false
---

## 关于 Gyoza

Gyoza 是一个使用 Astro 和 React 开发的博客主题。Gyoza 借鉴了 [Shiro](https://github.com/innei/Shiro) 和一些网站的设计。

Gyoza 的核心理念是简洁,快速,可爱。

- 在 [Markdown 示例](/posts/markdown) 中展示了 Markdown 的渲染样式
- 可以前往 [Gyoza 使用指南](/posts/guide) 了解 Gyoza 的使用方法

Gyoza 是开源的,如果你对这个项目感兴趣,欢迎前往 Gyoza 的 [Github 仓库](https://github.com/lxchapu/astro-gyoza) 来提 Issue 或者 PR。

## 关于作者

我是 lxchapu,是一名前端开发者,喜欢各种有趣的东西。欢迎访问我的个人网站 [www.lxchapu.com](https://www.lxchapu.com),了解关于我更多的信息。

## 致谢

感谢以下项目:

- [Astro](https://astro.build/)
- [React](https://reactjs.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Framer Motion](https://www.framer.com/motion/)
- [Jotai](https://jotai.org/)


================================================
FILE: src/content/spec/friends.md
================================================
---
title: 朋友们
description: 我的小伙伴们和一些有趣的站点。
comments: true
---

## 怎么申请友链?

想要交换友链的小伙伴们,欢迎去本站的 [Github 仓库](https://github.com/lxchapu/astro-gyoza/tree/main/src/content/friends)提交一个 PR。审核通过后,就可以在这里展示啦。

请在`/src/content/friends/`目录下添加一个`<short-name>.yaml`文件,参考格式:

```yml
title: 网站名称
description: 一句话介绍下你的网站或者你自己
link: 网站地址
avatar: 头像地址
```


================================================
FILE: src/content/spec/projects.md
================================================
---
title: 项目
description: 这些是我创建或参与的项目,如果你感兴趣不妨去给个 Star。
comments: false
---


================================================
FILE: src/env.d.ts
================================================
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />


================================================
FILE: src/hooks/useDebounceValue.ts
================================================
import { useEffect, useState } from 'react'

export function useDebounceValue<T>(value: T, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => {
      clearTimeout(handler)
    }
  }, [value, delay])

  return debouncedValue
}


================================================
FILE: src/layouts/Layout.astro
================================================
---
import {
  CommonHead,
  WebAnalytics,
  ThemeLoader,
  AccentColorInjector,
  PrintVersion,
} from '@/components/head'
import { BackToTopFAB } from '@/components/BackToTopFAB'
import { ToastContainer } from '@/components/ToastContainer'
import { ModalStack } from '@/components/ui/modal'
import { site } from '@/config.json'
import '@/styles/global.css'

interface Props {
  title?: string
  description?: string
  image?: string
}

const { title, description, image } = Astro.props
---

<!doctype html>
<html lang={site.lang}>
  <head>
    <CommonHead title={title} description={description} image={image} />
    <WebAnalytics />
    <ThemeLoader />
    <AccentColorInjector />
    <PrintVersion />
  </head>
  <body>
    <slot />

    <BackToTopFAB client:only="react" />
    <ToastContainer client:only="react" />
    <ModalStack client:only="react" />
  </body>
</html>


================================================
FILE: src/layouts/MarkdownLayout.astro
================================================
---
import Layout from './Layout.astro'
import { HeadGradient } from '@/components/head-gradient'
import Footer from '@/components/footer/Footer.astro'
import { Header } from '@/components/header/Header'
import { Provider } from '@/components/provider/Provider'
import 'katex/dist/katex.min.css'

interface Props {
  title: string
  description?: string
  image?: string
  mdTitle: string
  mdDescription: string
  mdSlug: string
}

const { title, description, image, mdTitle, mdDescription, mdSlug } = Astro.props
---

<Layout title={title} description={description} image={image}>
  <Provider
    pathName={Astro.url.pathname}
    title={mdTitle}
    description={mdDescription}
    slug={mdSlug}
    client:only="react"
  />

  <Header client:only="react" />
  <main class="relative z-1 pt-16 min-h-main bg-primary">
    <HeadGradient client:idle />
    <div class="swup-transition-fade">
      <slot />
    </div>
  </main>
  <Footer />
</Layout>


================================================
FILE: src/layouts/PageLayout.astro
================================================
---
import Layout from './Layout.astro'
import { Header } from '@/components/header/Header'
import Footer from '@/components/footer/Footer.astro'
import { Provider } from '@/components/provider/Provider'

interface Props {
  title?: string
  description?: string
  image?: string
}

const { title, description, image } = Astro.props
---

<Layout title={title} description={description} image={image}>
  <Provider pathName={Astro.url.pathname} client:only="react" />

  <Header client:only="react" />
  <main class="relative z-1 pt-16 min-h-main bg-primary">
    <div class="swup-transition-fade">
      <slot />
    </div>
  </main>
  <Footer />
</Layout>


================================================
FILE: src/pages/404.astro
================================================
---
import Layout from '@/layouts/Layout.astro'
import { Flashlight } from '@/components/Flashlight'
---

<Layout title="404">
  <div class="max-w-[800px] mx-auto py-16 px-4 md:px-8">
    <h1 class="text-[4rem] md:text-[9rem] leading-tight font-bold">Not Found</h1>
    <p class="text-3xl mb-24 text-gray-800/90 dark:text-gray-200/90">
      对不起,这个链接坏了。可能有些东西被删除,或者被移动了。无论如何,这里没什么可看的。
    </p>
    <a class="text-accent text-xl md:text-2xl inline-flex" href="/"
      ><i class="iconfont icon-arrow-left-up"></i>
      <span class="ml-2">传送回首页</span></a
    >
  </div>
  <Flashlight client:only="react" />
</Layout>


================================================
FILE: src/pages/[...page].astro
================================================
---
import type { GetStaticPaths } from 'astro'
import PageLayout from '@/layouts/PageLayout.astro'
import PostList from '@/components/post/PostList.astro'
import PostPagination from '@/components/post/PostPagination.astro'
import { getSortedPosts, getHotTags, getAllCategories, getAllTags } from '@/utils/content'
import appConfig from '@/config.json'
import SectionBlock from '@/components/SectionBlock.astro'
import TagList from '@/components/TagList.astro'
import CategoryList from '@/components/CategoryList.astro'
import type { CollectionEntry } from 'astro:content'
import Hero from '@/components/hero/Hero.astro'

export const getStaticPaths = (async () => {
  const sortedPosts = await getSortedPosts()
  const { perPage } = appConfig.posts
  const totalPage = Math.ceil(sortedPosts.length / perPage)

  const paths = Array.from({ length: totalPage }).map((_, i) => {
    const data = sortedPosts.slice(i * perPage, (i + 1) * perPage)
    const props = { currentPage: i + 1, totalPage, data }
    const params = {
      page: i === 0 ? undefined : `page/${i + 1}`,
    }
    return { params, props }
  })

  return paths
}) satisfies GetStaticPaths

interface Props {
  currentPage: number
  totalPage: number
  data: CollectionEntry<'posts'>[]
}

const { currentPage, totalPage, data } = Astro.props

const hotTags = await getHotTags()
const allTags = await getAllTags()
const allCategories = await getAllCategories()

const getPageUrl = (page: number) => {
  if (page === 1) return '/'
  return `/page/${page}`
}
---

<PageLayout>
  <div>
    {currentPage === 1 && <Hero />}
    <div class="max-w-[1100px] px-4 md:px-8 py-20 mx-auto grid lg:grid-cols-[auto_300px] gap-10">
      <div class="min-w-0">
        <SectionBlock title="最新发布">
          <PostList posts={data} />
          {
            totalPage > 1 && (
              <PostPagination current={currentPage} total={totalPage} getPageUrl={getPageUrl} />
            )
          }
        </SectionBlock>
      </div>
      <div>
        <aside class="md:sticky md:top-20 space-y-10">
          <SectionBlock title="分类">
            <CategoryList categories={allCategories} />
          </SectionBlock>
          <SectionBlock title="热门标签">
            <TagList tags={hotTags} />
            {
              allTags.length > hotTags.length && (
                <div class="mt-2 text-right">
                  <a class="text-sm text-secondary hover:text-accent" href="/tags">
                    更多标签 <i class="iconfont icon-arrow-right" />
                  </a>
                </div>
              )
            }
          </SectionBlock>
        </aside>
      </div>
    </div>
  </div>
</PageLayout>


================================================
FILE: src/pages/[spec].astro
================================================
---
import type { GetStaticPaths } from 'astro'
import type { CollectionEntry } from 'astro:content'
import { getCollection } from 'astro:content'
import MarkdownLayout from '@/layouts/MarkdownLayout.astro'
import PageLayout from '@/layouts/PageLayout.astro'
import Highlight from '@/components/Highlight.astro'
import SectionBlock from '@/components/SectionBlock.astro'
import MarkdownWrapper from '@/components/MarkdownWrapper.astro'
import FriendList from '@/components/FriendList.astro'
import ProjectList from '@/components/ProjectList.astro'
import { ReadingProgress } from '@/components/post/ReadingProgress'
import { PostToc } from '@/components/post/PostToc'
import { Comments } from '@/components/comment'

export const getStaticPaths = (async () => {
  const specList = await getCollection('spec')

  return specList.map((md) => ({
    params: { spec: md.slug },
    props: {
      md,
    },
  }))
}) satisfies GetStaticPaths

interface Props {
  md: CollectionEntry<'spec'>
}

const { md } = Astro.props

const { headings, Content } = await md.render()

const isPageLayout = ['friends', 'projects'].includes(md.slug)
const isMdContentEmpty = md.body.trim().length === 0
---

{
  isPageLayout ? (
    <PageLayout title={md.data.title} description={md.data.description}>
      <div class="max-w-[800px] mx-auto px-4 py-16 space-y-8" data-pagefind-body>
        <header class="space-y-4">
          <h1 class="text-4xl font-bold">
            <Highlight>{md.data.title}</Highlight>
          </h1>
          <p>{md.data.description}</p>
        </header>
        {md.slug === 'friends' && <FriendList />}
        {md.slug === 'projects' && <ProjectList />}
        {!isMdContentEmpty && (
          <MarkdownWrapper>
            <Content />
          </MarkdownWrapper>
        )}
        {md.data.comments && <Comments />}
      </div>
    </PageLayout>
  ) : (
    <MarkdownLayout
      title={md.data.title}
      description={md.data.description}
      mdTitle={md.data.title}
      mdDescription={md.data.description}
      mdSlug={md.slug}
    >
      <div
        class="max-w-[1100px] mx-auto px-4 md:px-8 py-16 grid lg:grid-cols-[auto_260px] gap-8"
        data-pagefind-body
      >
        <div>
          <header class="space-y-4">
            <h1 class="text-4xl font-bold">
              <Highlight>{md.data.title}</Highlight>
            </h1>
            <p>{md.data.description}</p>
          </header>
        </div>
        <div class="col-start-1 min-w-0">
          <MarkdownWrapper>
            <Content />
          </MarkdownWrapper>
        </div>
        <div class="hidden lg:block" data-pagefind-ignore>
          <aside class="sticky top-20">
            <SectionBlock title="目录">
              <PostToc headings={headings} client:idle />
              <hr class="my-2 border-primary max-w-[100px]" />
              <ReadingProgress client:idle />
            </SectionBlock>
          </aside>
        </div>
        {md.data.comments && <Comments />}
      </div>
    </MarkdownLayout>
  )
}


================================================
FILE: src/pages/archives.astro
================================================
---
import Timeline from '@/components/Timeline.astro'
import Highlight from '@/components/Highlight.astro'
import PageLayout from '@/layouts/PageLayout.astro'
import { getOldestPosts } from '@/utils/content'
import { TimelineProgress } from '@/components/TimelineProgress'

const oldestPosts = await getOldestPosts()
---

<PageLayout title="归档">
  <div class="max-w-[800px] mx-auto px-4 py-16">
    <header class="space-y-4 mb-8">
      <h1 class="text-4xl font-bold">
        <Highlight>归档</Highlight>
      </h1>
      <p>共产出 {oldestPosts.length} 篇文章,再接再厉。</p>
      <hr class="w-[100px] border-primary" />
      <TimelineProgress client:load />
    </header>
    <Timeline posts={oldestPosts} />
  </div>
</PageLayout>


================================================
FILE: src/pages/categories/[category].astro
================================================
---
import type { GetStaticPaths } from 'astro'
import PageLayout from '@/layouts/PageLayout.astro'
import { getAllCategories, getOldestPosts, slugify } from '@/utils/content'
import Highlight from '@/components/Highlight.astro'
import Timeline from '@/components/Timeline.astro'

export const getStaticPaths = (async () => {
  const allCategories = await getAllCategories()

  return allCategories.map((category) => {
    return {
      params: {
        category: category.slug,
      },
      props: { category },
    }
  })
}) satisfies GetStaticPaths

interface Props {
  category: {
    name: string
    slug: string
    count: number
  }
}

const { category } = Astro.props

const oldestPosts = await getOldestPosts().then((posts) => {
  return posts.filter((post) => post.data.category && slugify(post.data.category) === category.slug)
})
---

<PageLayout title={`分类 · ${category.name}`}>
  <div class="max-w-[800px] mx-auto px-4 py-16">
    <header class="space-y-4 mb-8">
      <h1 class="text-4xl font-bold">
        分类:<Highlight>{category.name}</Highlight>
      </h1>
      <p>共有 {category.count} 篇文章。</p>
    </header>
    <Timeline posts={oldestPosts} />
  </div>
</PageLayout>


================================================
FILE: src/pages/posts/[...slug].astro
================================================
---
import type { CollectionEntry } from 'astro:content'
import type { GetStaticPaths } from 'astro'
import MarkdownLayout from '@/layouts/MarkdownLayout.astro'
import { getSortedPosts } from '@/utils/content'
import MarkdownWrapper from '@/components/MarkdownWrapper.astro'
import SectionBlock from '@/components/SectionBlock.astro'
import { PostToc } from '@/components/post/PostToc'
import PostMetaInfo from '@/components/post/PostMetaInfo.astro'
import PostArchiveInfo from '@/components/post/PostArchiveInfo.astro'
import { ReadingProgress } from '@/components/post/ReadingProgress'
import { Outdate } from '@/components/post/Outdate'
import PostNav from '@/components/post/PostNav.astro'
import { Comments } from '@/components/comment'
import { ActionAside } from '@/components/post/ActionAside'
import { PostCopyright } from '@/components/post/PostCopyright'

export const getStaticPaths = (async () => {
  const sortedPosts = await getSortedPosts()

  return sortedPosts.map((post, index) => ({
    params: { slug: post.slug },
    props: {
      current: post,
      prev: index > 0 ? sortedPosts[index - 1] : undefined,
      next: index < sortedPosts.length - 1 ? sortedPosts[index + 1] : undefined,
    },
  }))
}) satisfies GetStaticPaths

interface Props {
  current: CollectionEntry<'posts'>
  prev?: CollectionEntry<'posts'>
  next?: CollectionEntry<'posts'>
}

const { current, prev, next } = Astro.props

const { headings, Content, remarkPluginFrontmatter } = await current.render()

const mdSlug = `/posts/${current.slug}`

const mdDescription = concat(current.data.category || '', current.data.tags.join(', '))

function concat(str1: string, str2: string, sep = ' / ') {
  if (str1 && str2) return str1 + sep + str2
  if (str1) return str1
  if (str2) return str2
  return ''
}

const lastMod = current.data.lastMod || current.data.date
---

<MarkdownLayout
  title={current.data.title}
  description={current.data.summary}
  image={current.data.cover}
  mdTitle={current.data.title}
  mdDescription={mdDescription}
  mdSlug={mdSlug}
>
  <div
    class="max-w-[1100px] mx-auto px-4 md:px-8 py-16 grid lg:grid-cols-[auto_260px] gap-8"
    data-pagefind-body
  >
    <div>
      <header class="space-y-4">
        <h1 class="text-4xl font-bold text-center">
          {current.data.title}
        </h1>
        <PostMetaInfo
          class="justify-center"
          date={current.data.date}
          lastMod={current.data.lastMod}
          words={remarkPluginFrontmatter.words}
          readingMinutes={remarkPluginFrontmatter.readingMinutes}
        />
        <PostArchiveInfo
          class="justify-center"
          category={current.data.category}
          tags={current.data.tags}
        />
        <div>
          <Outdate lastMod={lastMod} client:idle />
        </div>
      </header>
    </div>
    <div class="col-start-1 min-w-0">
      <MarkdownWrapper>
        <Content />
      </MarkdownWrapper>
    </div>
    <div class="hidden lg:block" data-pagefind-ignore>
      <aside class="sticky top-20">
        <SectionBlock title="目录">
          <PostToc headings={headings} client:idle />
          <hr class="my-2 border-primary max-w-[100px]" />
          <ReadingProgress client:idle />
        </SectionBlock>
        <ActionAside client:idle />
      </aside>
    </div>
    <div data-pagefind-ignore>
      <section class="space-y-6">
        <PostCopyright title={current.data.title} slug={mdSlug} lastMod={lastMod} client:visible />
        <PostNav prev={prev} next={next} />
        {current.data.comments && <Comments />}
      </section>
    </div>
  </div>
</MarkdownLayout>


================================================
FILE: src/pages/robots.txt.ts
================================================
import type { APIRoute } from 'astro'

const robotsTxt = `
User-agent: *
Allow: /

Disallow: /_astro/
Disallow: /fonts/

Sitemap: ${new URL('sitemap-index.xml', import.meta.env.SITE).href}
`.trim()

export const GET: APIRoute = () => {
  return new Response(robotsTxt, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
    },
  })
}


================================================
FILE: src/pages/rss.xml.ts
================================================
import type { APIContext } from 'astro'
import rss from '@astrojs/rss'
import { site } from '@/config.json'
import { getSortedPosts } from '@/utils/content'

export async function GET(context: APIContext) {
  const sortedPosts = await getSortedPosts()

  return rss({
    title: site.title,
    description: site.description,
    site: context.site!,
    items: sortedPosts.map((post) => ({
      link: `/posts/${post.slug}`,
      title: post.data.title,
      pubDate: post.data.date,
      description: post.data.summary,
    })),
    customData: `<language>${site.lang}</language>`,
  })
}


================================================
FILE: src/pages/tags/[tag].astro
================================================
---
import type { GetStaticPaths } from 'astro'
import { getAllTags, getOldestPosts, slugify } from '@/utils/content'
import PageLayout from '@/layouts/PageLayout.astro'
import Timeline from '@/components/Timeline.astro'
import Highlight from '@/components/Highlight.astro'

export const getStaticPaths = (async () => {
  const allTags = await getAllTags()

  return allTags.map((tag) => {
    return {
      params: {
        tag: tag.slug,
      },
      props: { tag },
    }
  })
}) satisfies GetStaticPaths

interface Props {
  tag: {
    name: string
    slug: string
    count: number
  }
}

const { tag } = Astro.props

const oldestPosts = await getOldestPosts().then((posts) => {
  return posts.filter((post) => post.data.tags.findIndex((t) => slugify(t) === tag.slug) >= 0)
})
---

<PageLayout title={`标签 · ${tag.name}`}>
  <div class="max-w-[800px] mx-auto px-4 py-16">
    <header class="space-y-4 mb-8">
      <h1 class="text-4xl font-bold">
        标签:<Highlight>{tag.name}</Highlight>
      </h1>
      <p>共有 {tag.count} 篇文章。</p>
    </header>
    <Timeline posts={oldestPosts} />
  </div>
</PageLayout>


================================================
FILE: src/pages/tags/index.astro
================================================
---
import TagList from '@/components/TagList.astro'
import Highlight from '@/components/Highlight.astro'
import { getAllTags } from '@/utils/content'
import PageLayout from '@/layouts/PageLayout.astro'

const allTags = await getAllTags()
---

<PageLayout title="标签">
  <div class="max-w-[800px] mx-auto px-4 py-16">
    <header class="space-y-4 mb-8">
      <h1 class="text-4xl font-bold">
        <Highlight>标签云</Highlight>
      </h1>
      <p>共有 {allTags.length} 个标签。</p>
    </header>

    <TagList tags={allTags} />
  </div>
</PageLayout>


================================================
FILE: src/plugins/rehypeCodeBlock.js
================================================
import { h } from 'hastscript'
import { visit } from 'unist-util-visit'

export function rehypeCodeBlock() {
  return function (tree) {
    visit(tree, { tagName: 'pre' }, (node, index, parent) => {
      const child = node.children[0]
      if (!child || child.type !== 'element' || child.tagName !== 'code' || !child.properties) {
        return
      }
      const classes = child.properties.className
      let lang = ''
      if (!classes) {
        node.children[0].properties = {
          className: ['language-text'],
        }
        lang = 'text'
      } else {
        lang = classes[0].slice(9)
      }

      const codeBlock = h(
        'div',
        {
          class: 'code-block',
        },
        [h('span', { class: 'lang-tag' }, lang), node],
      )

      parent.children[index] = codeBlock
    })
  }
}


================================================
FILE: src/plugins/rehypeCodeHighlight.js
================================================
import rehypeShiki from '@shikijs/rehype'

export const rehypeCodeHighlight = [
  rehypeShiki,
  {
    themes: {
      light: 'github-light',
      dark: 'github-dark',
    },
    defaultColor: false,
  },
]


================================================
FILE: src/plugins/rehypeHeading.js
================================================
import { h } from 'hastscript'
import { visit } from 'unist-util-visit'

export function rehypeHeading() {
  return (tree) => {
    visit(tree, 'element', (node, index, parent) => {
      if (
        node.tagName === 'h1' ||
        node.tagName === 'h2' ||
        node.tagName === 'h3' ||
        node.tagName === 'h4' ||
        node.tagName === 'h5' ||
        node.tagName === 'h6'
      ) {
        const link = h(
          'a',
          {
            href: `#${node.properties.id}`,
            class: 'heading-anchor',
            ariaLabel: 'Heading Anchor',
          },
          h('i', { class: 'iconfont icon-link' }),
        )
        node.children.push(link)
        node.properties = {
          ...node.properties,
          class: 'heading',
        }
        parent.children[index] = node
      }
    })
  }
}


================================================
FILE: src/plugins/rehypeImage.js
================================================
import { h } from 'hastscript'
import { visit } from 'unist-util-visit'

export function rehypeImage() {
  return function (tree) {
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === 'p' && node.children.length === 1) {
        const child = node.children[0]
        if (child.tagName === 'img') {
          parent.children[index] = buildFigure(child)
        }
      } else if (node.tagName === 'img') {
        parent.children[index] = buildImage(node)
      }
    })
  }
}

function buildImage(node) {
  const imgProps = node.properties

  return h('img', { ...imgProps, loading: 'lazy' })
}

function buildFigure(node) {
  let imgTitle = node.properties.title
  if (imgTitle) {
    imgTitle = imgTitle.trim()
  }

  return h('figure', null, [buildImage(node), imgTitle ? h('figcaption', imgTitle) : null])
}


================================================
FILE: src/plugins/rehypeLink.js
================================================
import { h } from 'hastscript'
import { visit } from 'unist-util-visit'

export function rehypeLink() {
  return (tree) => {
    visit(tree, { tagName: 'a' }, (node, index, parent) => {
      const isExternal = node.properties.href.startsWith('http')
      if (isExternal) {
        node.properties = {
          ...node.properties,
          rel: 'noopener noreferrer',
          target: '_blank',
        }
        parent.children[index] = node
        const icon = h('i', { class: 'iconfont icon-external-link' })
        parent.children.splice(index + 1, 0, icon)
      }
    })
  }
}


================================================
FILE: src/plugins/rehypeTableBlock.js
================================================
import { h } from 'hastscript'
import { visit } from 'unist-util-visit'

export function rehypeTableBlock() {
  return function (tree) {
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === 'table') {
        const wrapper = h('div', { class: 'table-wrapper' }, [node])
        parent.children[index] = wrapper
      }
      if (node.tagName === 'th' || node.tagName === 'td') {
        const align = node.properties.align
        if (align) {
          node.properties.style = `text-align: ${align};`
          delete node.properties.align
        }
      }
    })
  }
}


================================================
FILE: src/plugins/remarkEmbed.js
================================================
import { visit } from 'unist-util-visit'

export function remarkEmbed() {
  return function (tree) {
    visit(tree, (node) => {
      if (node.type === 'leafDirective') {
        if (!['youtube', 'bilibili', 'codepen'].includes(node.name)) return

        const data = node.data || (node.data = {})
        const attributes = node.attributes || {}
        const id = attributes.id

        if (!id) return

        data.hName = 'iframe'
        switch (node.name) {
          case 'youtube':
            data.hProperties = {
              class: 'video',
              title: 'YouTube Video Player',
              src: `https://www.youtube.com/embed/${id}`,
              frameBorder: 0,
              allow:
                'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
              allowFullScreen: true,
              loading: 'lazy',
            }
            break
          case 'bilibili':
            data.hProperties = {
              class: 'video',
              title: 'Bilibili Video Player',
              src: `//player.bilibili.com/player.html?isOutside=true&bvid=${id}`,
              frameBorder: 0,
              allowFullScreen: true,
              loading: 'lazy',
            }
            break
          case 'codepen':
            data.hProperties = {
              class: 'codepen',
              title: 'CodePen Embed',
              src: `https://codepen.io/${attributes.author}/embed/${id}`,
              frameBorder: 0,
              allowFullScreen: true,
              loading: 'lazy',
            }
            break
        }
      }
    })
  }
}


================================================
FILE: src/plugins/remarkReadingTime.js
================================================
import getReadingTime from 'reading-time'
import { toString } from 'mdast-util-to-string'

export function remarkReadingTime() {
  return function (tree, { data }) {
    const textOnPage = toString(tree)
    const readingTime = getReadingTime(textOnPage)
    data.astro.frontmatter.readingMinutes = readingTime.minutes
    data.astro.frontmatter.words = readingTime.words
  }
}


================================================
FILE: src/plugins/remarkSpoiler.js
================================================
import { codes, types, constants } from 'micromark-util-symbol'

export function remarkSpoiler() {
  const self = this
  const data = self.data()

  const micromarkExtensions = data.micromarkExtensions || (data.micromarkExtensions = [])
  const fromMarkdownExtensions = data.fromMarkdownExtensions || (data.fromMarkdownExtensions = [])

  micromarkExtensions.push(spoilerSyntax())
  fromMarkdownExtensions.push(spoilerFromMarkdown())
}

function spoilerSyntax() {
  return {
    text: {
      [codes.verticalBar]: spoilerConstruct,
    },
  }
}

const spoilerConstruct = { name: 'spoiler', tokenize: spoilerTokenize }
const markerConstruct = { tokenize: markerTokenize, partial: true }

function spoilerTokenize(effects, ok, nok) {
  function start() {
    effects.enter('spoiler')
    return effects.attempt(markerConstruct, contentStart, nok)
  }

  function contentStart() {
    effects.enter(types.chunkText, {
      contentType: constants.contentTypeText,
    })
    return content
  }

  function content() {
    return effects.check(markerConstruct, contentEnd, consumeData)
  }

  function consumeData(code) {
    if (code === codes.eof || code < codes.horizontalTab) {
      return nok
    }
    effects.consume(code)
    return content
  }

  function contentEnd() {
    effects.exit(types.chunkText)
    return effects.attempt(markerConstruct, after, nok)
  }

  function after() {
    effects.exit('spoiler')
    return ok
  }

  return start
}

function markerTokenize(effects, ok, nok) {
  const previous = this.previous
  let markSize = 0

  function start() {
    if (previous === codes.verticalBar) {
      return nok
    }
    effects.enter('spoilerMarker')
    return marker
  }

  function marker(code) {
    if (code === codes.verticalBar) {
      if (markSize > 1) {
        return nok
      }
      effects.consume(code)
      markSize++
      return marker
    }
    if (markSize < 2) {
      return nok
    }
    effects.exit('spoilerMarker')
    return ok
  }

  return start
}

function spoilerFromMarkdown() {
  function enterHandler(token) {
    this.enter(
      {
        type: 'spoiler',
        children: [],
      },
      token,
    )
  }

  function exitHandler(token) {
    const node = this.stack[this.stack.length - 1]
    this.exit(token)
    node.data = {
      ...node.data,
      hName: 'span',
      hProperties: {
        className: 'spoiler',
        title: '你知道的太多了',
      },
    }
  }

  return {
    enter: {
      spoiler: enterHandler,
    },
    exit: {
      spoiler: exitHandler,
    },
  }
}


================================================
FILE: src/store/metaInfo.ts
================================================
import { atom } from 'jotai'

export const pathNameAtom = atom('')
export const metaTitleAtom = atom('')
export const metaDescriptionAtom = atom('')
export const metaSlugAtom = atom('')

export const hasMetaInfoAtom = atom((get) => {
  const title = get(metaTitleAtom)
  const description = get(metaDescriptionAtom)
  const slug = get(metaSlugAtom)
  return title !== '' && description !== '' && slug !== ''
})


================================================
FILE: src/store/modalStack.ts
================================================
import { atom } from 'jotai'

export const modalStackAtom = atom<
  {
    id: string
    content: React.ReactNode
  }[]
>([])


================================================
FILE: src/store/scrollInfo.ts
================================================
import { atom } from 'jotai'

export const pageScrollLocationAtom = atom(0)
export const pageScrollDirectionAtom = atom<'up' | 'down' | null>(null)


================================================
FILE: src/store/theme.ts
================================================
import { getLocalTheme } from '@/utils/theme'
import { atom } from 'jotai'

export const themeAtom = atom(getLocalTheme())


================================================
FILE: src/store/viewport.ts
================================================
import { atom } from 'jotai'

export const isMobileAtom = atom(false)


================================================
FILE: src/styles/global.css
================================================
@import './iconfont.css';
@import './shiki.css';
@import './markdown.css';
@import './signature.css';
@import './swup.css';

@font-face {
  font-family: 'Atkinson';
  src: url('/fonts/atkinson-regular.woff') format('woff');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Atkinson';
  src: url('/fonts/atkinson-bold.woff') format('woff');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

::selection {
  background-color: theme('colors.accent');
  color: white;
}

[data-theme='dark'] ::selection {
  background-color: theme('colors.accent/0.3');
}

html {
  color: theme('textColor.primary');
  background-color: theme('backgroundColor.root');
  scroll-padding-top: 64px;
}

html.gray {
  filter: grayscale(1);
}

* {
  scrollbar-width: thin;
}


================================================
FILE: src/styles/iconfont.css
================================================
@font-face {
  font-family: 'iconfont';
  /* Project id 4528205 */
  src:
    url('/fonts/iconfont.woff2?t=1716210197380') format('woff2'),
    url('/fonts/iconfont.woff?t=1716210197380') format('woff'),
    url('/fonts/iconfont.ttf?t=1716210197380') format('truetype');
}

.iconfont {
  font-family: 'iconfont';
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.icon-alert:before {
  content: '\e83a';
}

.icon-check:before {
  content: '\e847';
}

.icon-error:before {
  content: '\e854';
}

.icon-info:before {
  content: '\e85f';
}

.icon-close:before {
  content: '\e84a';
}

.icon-user-heart:before {
  content: '\e88c';
}

.icon-wechat:before {
  content: '\e7e3';
}

.icon-codepen:before {
  content: '\e7cd';
}

.icon-instagram:before {
  content: '\e7d3';
}

.icon-playstation:before {
  content: '\e7d9';
}

.icon-qq:before {
  content: '\e7da';
}

.icon-steam:before {
  content: '\e7e2';
}

.icon-switch:before {
  content: '\e7e5';
}

.icon-telegram:before {
  content: '\e7e6';
}

.icon-twitch:before {
  content: '\e7e7';
}

.icon-weibo:before {
  content: '\e7e8';
}

.icon-xbox:before {
  content: '\e7e9';
}

.icon-github:before {
  content: '\e7d2';
}

.icon-down:before {
  content: '\e83d';
}

.icon-map:before {
  content: '\e7fa';
}

.icon-more:before {
  content: '\e877';
}

.icon-left:before {
  content: '\e844';
}

.icon-right:before {
  content: '\e845';
}

.icon-rss:before {
  content: '\e74b';
}

.icon-ghost:before {
  content: '\e882';
}

.icon-contacts-book:before {
  content: '\e760';
}

.icon-x:before {
  content: '\eced';
}

.icon-pen:before {
  content: '\e720';
}

.icon-t-box:before {
  content: '\e727';
}

.icon-hearts:before {
  content: '\e7c1';
}

.icon-text:before {
  content: '\e7a8';
}

.icon-netease-cloud-music:before {
  content: '\e7d7';
}

.icon-pushpin:before {
  content: '\e7f1';
}

.icon-arrow-left-up:before {
  content: '\e842';
}

.icon-computer:before {
  content: '\e73a';
}

.icon-file-list:before {
  content: '\e76d';
}

.icon-link:before {
  content: '\e79b';
}

.icon-flask:before {
  content: '\e7bd';
}

.icon-douban:before {
  content: '\e7cc';
}

.icon-folder:before {
  content: '\e784';
}

.icon-hashtag:before {
  content: '\e798';
}

.icon-archive:before {
  content: '\e6be';
}

.icon-calendar:before {
  content: '\e6c9';
}

.icon-mail:before {
  content: '\e6da';
}

.icon-pantone:before {
  content: '\e71d';
}

.icon-quotes-l:before {
  content: '\e793';
}

.icon-quotes-r:before {
  content: '\e794';
}

.icon-heart:before {
  content: '\e7bf';
}

.icon-bilibili:before {
  content: '\e7c8';
}

.icon-zhihu:before {
  content: '\e7e4';
}

.icon-rocket:before {
  content: '\e7f3';
}

.icon-arrow-left:before {
  content: '\e841';
}

.icon-arrow-right:before {
  content: '\e843';
}

.icon-external-link:before {
  content: '\e853';
}

.icon-loader:before {
  content: '\e85d';
}

.icon-menu:before {
  content: '\e864';
}

.icon-search:before {
  content: '\e86e';
}

.icon-share:before {
  content: '\e874';
}

.icon-timer:before {
  content: '\e87c';
}

.icon-moon:before {
  content: '\e89c';
}

.icon-sun:before {
  content: '\e89e';
}


================================================
FILE: src/styles/markdown.css
================================================
.markdown > :first-child {
  @apply mt-0;
}

.markdown > :last-child {
  @apply mb-0;
}

.markdown p {
  @apply mb-5;
}

.markdown h1 {
  @apply text-4xl font-extrabold mb-8;
}

.markdown h2 {
  @apply text-2xl font-bold mt-12 mb-6;
}

.markdown h3 {
  @apply text-xl font-semibold mt-8 mb-3;
}

.markdown h4 {
  @apply font-semibold mt-6 mb-2;
}

.markdown a {
  @apply font-normal text-accent hover:underline underline-offset-2;
}

.markdown a + .icon-external-link {
  @apply text-secondary ml-1;
}

.markdown blockquote {
  @apply my-5 pl-4 border-l-2 border-accent/80 text-secondary italic;
}

.markdown blockquote::before {
  content: '\e793';
  font-family: 'iconfont';
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  @apply not-italic text-accent/80;
}

.markdown blockquote > p:last-child {
  @apply mb-0;
}

.markdown :not(pre) > code {
  @apply px-2 py-1 rounded bg-secondary text-sm font-semibold;
}

.markdown .code-block {
  @apply relative mt-10 mb-5 bg-secondary rounded-lg;
}

.markdown .lang-tag {
  @apply absolute right-6 -top-6 h-6 px-4 flex items-center rounded-t-lg bg-inherit text-sm;
}

.markdown pre {
  @apply px-6 py-4 max-h-[450px] text-sm leading-relaxed overflow-auto;
}

.markdown .table-wrapper {
  @apply overflow-x-auto my-5;
}

.markdown table {
  @apply w-auto mx-auto text-left table-auto text-sm;
}

.markdown tr {
  @apply border-b border-primary;
}

.markdown th,
.markdown td {
  @apply p-2;
}

.markdown td {
  @apply align-baseline;
}

.markdown img {
  @apply rounded-lg bg-secondary min-h-[80px] min-w-[80px];
}

.markdown figure {
  @apply mb-5 flex flex-col items-center;
}

.markdown figcaption {
  @apply mt-3 text-secondary text-sm;
}

.markdown .heading-anchor {
  @apply ml-1 opacity-0 hover:no-underline;
}

.markdown .heading:hover .heading-anchor,
.markdown .heading-anchor:focus-visible {
  @apply opacity-100;
}

.markdown iframe {
  @apply rounded-lg w-full bg-secondary;
}

.markdown iframe.codepen {
  @apply min-h-[450px];
}

.markdown iframe.video {
  @apply aspect-video;
}

.markdown hr {
  @apply my-12 mx-auto max-w-[100px] border-primary;
}

.markdown ul {
  @apply list-disc;
}

.markdown ol {
  @apply list-decimal;
}

.markdown ol,
.markdown ul {
  @apply my-5 pl-6 space-y-2;
}

.markdown ul ul,
.markdown ul ol,
.markdown ol ul,
.markdown ol ol {
  @apply my-3;
}

.markdown .spoiler {
  @apply bg-current rounded transition-bg-color not-italic;
}

.markdown .spoiler:hover {
  @apply bg-transparent;
}

.markdown h2 + * {
  @apply mt-0;
}

.markdown h3 + * {
  @apply mt-0;
}

.markdown h4 + * {
  @apply mt-0;
}


================================================
FILE: src/styles/shiki.css
================================================
.shiki,
.shiki span {
  color: var(--shiki-light);
}

[data-theme='dark'] .shiki,
[data-theme='dark'] .shiki span {
  color: var(--shiki-dark);
}


================================================
FILE: src/styles/signature.css
================================================
.animated-signature path {
  stroke-dasharray: 2400;
  stroke-dashoffset: 2400;
  fill: transparent;
  animation: drawSignature 8s linear infinite both;
  stroke-width: 2px;
  stroke: theme('textColor.primary');
}

@keyframes drawSignature {
  0% {
    stroke-dashoffset: 2400;
  }

  15% {
    fill: transparent;
  }

  35%,
  75% {
    stroke-dashoffset: 0;
    fill: theme('textColor.primary');
  }

  90%,
  to {
    stroke-dashoffset: 2400;
    fill: transparent;
  }
}


================================================
FILE: src/styles/swup.css
================================================
html.is-changing .swup-transition-fade {
  transition: 0.4s;
  opacity: 1;
}
html.is-animating .swup-transition-fade {
  opacity: 0;
}


================================================
FILE: src/utils/content.ts
================================================
import { getCollection } from 'astro:content'

// 获取所有文章
async function getAllPosts() {
  const allPosts = await getCollection('posts', ({ data }) => {
    return import.meta.env.PROD ? data.draft !== true : true
  })

  return allPosts
}

// 获取所有文章,发布日期升序
async function getNewestPosts() {
  const allPosts = await getAllPosts()

  return allPosts.sort((a, b) => {
    return a.data.date.valueOf() - b.data.date.valueOf()
  })
}

// 获取所有文章,发布日期降序
export async function getOldestPosts() {
  const allPosts = await getAllPosts()

  return allPosts.sort((a, b) => {
    return b.data.date.valueOf() - a.data.date.valueOf()
  })
}

// 获取所有文章,置顶优先,发布日期降序
export async function getSortedPosts() {
  const allPosts = await getAllPosts()

  return allPosts.sort((a, b) => {
    if (a.data.sticky !== b.data.sticky) {
      return b.data.sticky - a.data.sticky
    } else {
      return b.data.date.valueOf() - a.data.date.valueOf()
    }
  })
}

// 获取所有文章的字数
export async function getAllPostsWordCount() {
  const allPosts = await getAllPosts()

  const promises = allPosts.map((post) => {
    return post.render()
  })

  const res = await Promise.all(promises)

  const wordCount = res.reduce((count, cur) => {
    return count + cur.remarkPluginFrontmatter.words
  }, 0)

  return wordCount
}

// 转换为 URL 安全的 slug,删除点,空格转为短横线,大写转为小写
export function slugify(text: string) {
  return text.replace(/\./g, '').replace(/\s/g, '-').toLowerCase()
}

// 获取所有分类
export async function getAllCategories() {
  const newestPosts = await getNewestPosts()

  const allCategories = newestPosts.reduce<{ slug: string; name: string; count: number }[]>(
    (acc, cur) => {
      if (cur.data.category) {
        const slug = slugify(cur.data.category)
        const index = acc.findIndex((category) => category.slug === slug)
        if (index === -1) {
          acc.push({
            slug,
            name: cur.data.category,
            count: 1,
          })
        } else {
          acc[index].count += 1
        }
      }
      return acc
    },
    [],
  )

  return allCategories
}

// 获取所有标签
export async function getAllTags() {
  const newestPosts = await getNewestPosts()

  const allTags = newestPosts.reduce<{ slug: string; name: string; count: number }[]>(
    (acc, cur) => {
      cur.data.tags.forEach((tag) => {
        const slug = slugify(tag)
        const index = acc.findIndex((tag) => tag.slug === slug)
        if (index === -1) {
          acc.push({
            slug,
            name: tag,
            count: 1,
          })
        } else {
          acc[index].count += 1
        }
      })
      return acc
    },
    [],
  )

  return allTags
}

// 获取热门标签
export async function getHotTags(len = 5) {
  const allTags = await getAllTags()

  return allTags
    .sort((a, b) => {
      return b.count - a.count
    })
    .slice(0, len)
}


================================================
FILE: src/utils/date.ts
================================================
// 获取两个日期的相对时间
export function getRelativeTime(startDate: Date, endDate = new Date()) {
  const diffSeconds = Math.floor((endDate.getTime() - startDate.getTime()) / 1000)
  if (diffSeconds < 0) {
    return null
  }
  const diffMinutes = Math.floor(diffSeconds / 60)
  if (diffMinutes < 10) {
    return '刚刚'
  }
  if (diffMinutes < 60) {
    return `${diffMinutes} 分钟前`
  }
  const diffHours = Math.floor(diffMinutes / 60)
  if (diffHours < 24) {
    return `${diffHours} 小时前`
  }
  const diffDays = Math.floor(diffHours / 24)
  if (diffDays < 10) {
    return `${diffDays} 天前`
  }
  return null
}

// 获取一个格式化的日期,格式为:2024 年 1 月 1 日 星期一
export function getFormattedDate(date: Date) {
  const year = date.getFullYear() % 100
  const month = date.getMonth() + 1
  const day = date.getDate()
  const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][date.getDay()]

  return `${year} 年 ${month} 月 ${day} 日 ${week}`
}

// 数字前补 0
function padZero(number: number, len = 2) {
  return number.toString().padStart(len, '0')
}

// 获取格式化后的日期时间,格式:2024 年 01 月 01 日 12:00
export function getFormattedDateTime(date: Date) {
  const year = date.getFullYear()
  const month = padZero(date.getMonth() + 1)
  const day = padZero(date.getDate())
  const hours = padZero(date.getHours())
  const minutes = padZero(date.getMinutes())

  return `${year} 年 ${month} 月 ${day} 日 ${hours}:${minutes}`
}

// 获取两个日期的相差的天数
export function getDiffInDays(startDate: Date, endDate = new Date()) {
  return Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 86400))
}

// 获取一个短的日期,格式为:04-20
export function getShortDate(date: Date) {
  const month = padZero(date.getMonth() + 1)
  const day = padZero(date.getDate())

  return `${month}-${day}`
}

// 获取日期所在的年一共多少天
export function getDaysInYear(date: Date) {
  const year = date.getFullYear()
  if ((year % 4 === 0 && year % 100 !== 0) || year % 400 === 0) {
    return 366
  }
  return 365
}

// 获取日期所在的年的开始日期
export function getStartOfYear(date: Date) {
  const year = date.getFullYear()
  return new Date(year, 0, 1)
}

// 获取日期所在的天的开始日期
export function getStartOfDay(date: Date) {
  return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}


================================================
FILE: src/utils/theme.ts
================================================
export function changePageTheme(theme: string) {
  document.documentElement.setAttribute('data-theme', theme)
}

export function getSystemTheme() {
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}

const themeKey = 'gyoza-theme'

export function getLocalTheme() {
  const local = localStorage.getItem(themeKey)
  if (local === 'dark' || local === 'light') {
    return local
  } else {
    setLocalTheme('system')
    return 'system'
  }
}

export function setLocalTheme(theme: string) {
  localStorage.setItem(themeKey, theme)
}


================================================
FILE: tailwind.config.ts
================================================
import type { Config } from 'tailwindcss'

const config: Config = {
  content: ['./src/**/*.{astro,ts,tsx,js,jsx}'],
  darkMode: ['selector', '[data-theme="dark"]'],
  theme: {
    fontFamily: {
      sans: [
        '"Noto Sans SC"',
        '"Source Han Sans SC"',
        'sans-serif',
        '"Apple Color Emoji"',
        '"Segoe UI Emoji"',
        '"Segoe UI Symbol"',
        '"Noto Color Emoji"',
      ],
      serif: ['"Noto Serif SC"', '"Source Han Serif SC"', '"Source Han Serif"', 'serif'],
      mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
    },
    fontSize: {
      xs: '0.75rem',
      sm: '0.875rem',
      base: '1rem',
      lg: '1.125rem',
      xl: '1.25rem',
      '2xl': '1.5rem',
      '3xl': '1.875rem',
      '4xl': '2.25rem',
      '5xl': '3rem',
    },
    extend: {
      colors: {
        accent: 'rgb(var(--color-accent) / <alpha-value>)',
      },
      textColor: {
        primary: 'rgb(var(--color-text-primary))',
        secondary: 'rgb(var(--color-text-secondary))',
      },
      backgroundColor: {
        root: 'rgb(var(--color-bg-root))',
        primary: 'rgb(var(--color-bg-primary))',
        secondary: 'rgb(var(--color-bg-secondary))',
      },
      borderColor: {
        primary: 'rgb(var(--color-border-primary))',
      },
      minHeight: {
        main: 'calc(100vh - 200px)',
      },
      transitionProperty: {
        'bg-color': 'background-color',
      },
      zIndex: {
        '1': '1',
      },
    },
  },
}

export default config


================================================
FILE: tsconfig.json
================================================
{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "strictNullChecks": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "jsx": "react-jsx",
    "jsxImportSource": "react"
  },
  "include": ["src/**/*"]
}
Download .txt
gitextract_63w4a159/

├── .gitattributes
├── .github/
│   └── workflows/
│       └── close-inactive-issues.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode/
│   ├── extensions.json
│   └── launch.json
├── LICENSE
├── README.md
├── astro.config.js
├── commitlint.config.js
├── package.json
├── scripts/
│   ├── new-friend.js
│   ├── new-post.js
│   ├── new-project.js
│   └── utils.js
├── src/
│   ├── components/
│   │   ├── AnimatedSignature.tsx
│   │   ├── BackToTopFAB.tsx
│   │   ├── CategoryList.astro
│   │   ├── Flashlight.tsx
│   │   ├── FriendList.astro
│   │   ├── Highlight.astro
│   │   ├── MarkdownWrapper.astro
│   │   ├── ProjectList.astro
│   │   ├── RootPortal.tsx
│   │   ├── SectionBlock.astro
│   │   ├── TagList.astro
│   │   ├── Timeline.astro
│   │   ├── TimelineProgress.tsx
│   │   ├── ToastContainer.tsx
│   │   ├── comment/
│   │   │   ├── Comments.astro
│   │   │   ├── Waline.tsx
│   │   │   └── index.ts
│   │   ├── footer/
│   │   │   ├── Footer.astro
│   │   │   ├── Link.astro
│   │   │   ├── RunningDays.tsx
│   │   │   └── ThemeSwitch.tsx
│   │   ├── head/
│   │   │   ├── AccentColorInjector.astro
│   │   │   ├── CommonHead.astro
│   │   │   ├── PrintVersion.astro
│   │   │   ├── ThemeLoader.astro
│   │   │   ├── WebAnalytics.tsx
│   │   │   └── index.ts
│   │   ├── head-gradient/
│   │   │   ├── HeadGradient.tsx
│   │   │   └── index.ts
│   │   ├── header/
│   │   │   ├── AnimatedLogo.tsx
│   │   │   ├── BluredBackground.tsx
│   │   │   ├── Header.tsx
│   │   │   ├── HeaderContent.tsx
│   │   │   ├── HeaderDrawer.tsx
│   │   │   ├── HeaderMeta.tsx
│   │   │   ├── SearchButton.tsx
│   │   │   └── hooks.ts
│   │   ├── hero/
│   │   │   ├── Hero.astro
│   │   │   └── SocialList.tsx
│   │   ├── post/
│   │   │   ├── ActionAside.tsx
│   │   │   ├── Outdate.tsx
│   │   │   ├── PostArchiveInfo.astro
│   │   │   ├── PostCard.astro
│   │   │   ├── PostCardHoverOverlay.tsx
│   │   │   ├── PostCopyright.tsx
│   │   │   ├── PostList.astro
│   │   │   ├── PostMetaInfo.astro
│   │   │   ├── PostNav.astro
│   │   │   ├── PostPagination.astro
│   │   │   ├── PostToc.tsx
│   │   │   ├── ReadingProgress.tsx
│   │   │   └── RelativeDate.tsx
│   │   ├── provider/
│   │   │   ├── HeaderMetaInfoProvider.tsx
│   │   │   ├── PageScrollInfoProvider.tsx
│   │   │   ├── Provider.tsx
│   │   │   ├── ThemeProvider.tsx
│   │   │   └── ViewportProvider.tsx
│   │   └── ui/
│   │       └── modal/
│   │           ├── Modal.tsx
│   │           ├── ModalStack.tsx
│   │           ├── context.ts
│   │           ├── hooks.ts
│   │           └── index.ts
│   ├── config.json
│   ├── content/
│   │   ├── config.ts
│   │   ├── friends/
│   │   │   ├── Keigo.yml
│   │   │   ├── astro-docs.yaml
│   │   │   └── lxchapu.yaml
│   │   ├── posts/
│   │   │   ├── embed.md
│   │   │   ├── guide.md
│   │   │   ├── how-to-use-icons.md
│   │   │   └── markdown.md
│   │   ├── projects/
│   │   │   └── gyoza.yaml
│   │   └── spec/
│   │       ├── about.md
│   │       ├── friends.md
│   │       └── projects.md
│   ├── env.d.ts
│   ├── hooks/
│   │   └── useDebounceValue.ts
│   ├── layouts/
│   │   ├── Layout.astro
│   │   ├── MarkdownLayout.astro
│   │   └── PageLayout.astro
│   ├── pages/
│   │   ├── 404.astro
│   │   ├── [...page].astro
│   │   ├── [spec].astro
│   │   ├── archives.astro
│   │   ├── categories/
│   │   │   └── [category].astro
│   │   ├── posts/
│   │   │   └── [...slug].astro
│   │   ├── robots.txt.ts
│   │   ├── rss.xml.ts
│   │   └── tags/
│   │       ├── [tag].astro
│   │       └── index.astro
│   ├── plugins/
│   │   ├── rehypeCodeBlock.js
│   │   ├── rehypeCodeHighlight.js
│   │   ├── rehypeHeading.js
│   │   ├── rehypeImage.js
│   │   ├── rehypeLink.js
│   │   ├── rehypeTableBlock.js
│   │   ├── remarkEmbed.js
│   │   ├── remarkReadingTime.js
│   │   └── remarkSpoiler.js
│   ├── store/
│   │   ├── metaInfo.ts
│   │   ├── modalStack.ts
│   │   ├── scrollInfo.ts
│   │   ├── theme.ts
│   │   └── viewport.ts
│   ├── styles/
│   │   ├── global.css
│   │   ├── iconfont.css
│   │   ├── markdown.css
│   │   ├── shiki.css
│   │   ├── signature.css
│   │   └── swup.css
│   └── utils/
│       ├── content.ts
│       ├── date.ts
│       └── theme.ts
├── tailwind.config.ts
└── tsconfig.json
Download .txt
SYMBOL INDEX (109 symbols across 52 files)

FILE: scripts/new-friend.js
  function getFriendFullPath (line 6) | function getFriendFullPath(fileName) {

FILE: scripts/new-post.js
  function getPostFullPath (line 6) | function getPostFullPath(fileName) {

FILE: scripts/new-project.js
  function getProjectFullPath (line 6) | function getProjectFullPath(fileName) {

FILE: scripts/utils.js
  function isFileNameSafe (line 1) | function isFileNameSafe(fileName) {

FILE: src/components/AnimatedSignature.tsx
  function AnimatedSignature (line 3) | function AnimatedSignature() {

FILE: src/components/BackToTopFAB.tsx
  function BackToTopFAB (line 5) | function BackToTopFAB() {
  function BackToTop (line 16) | function BackToTop() {

FILE: src/components/Flashlight.tsx
  function Flashlight (line 3) | function Flashlight() {

FILE: src/components/RootPortal.tsx
  function RootPortal (line 3) | function RootPortal({

FILE: src/components/TimelineProgress.tsx
  function TimelineProgress (line 5) | function TimelineProgress() {
  function CountUp (line 46) | function CountUp({

FILE: src/components/ToastContainer.tsx
  function ToastContainer (line 4) | function ToastContainer() {
  function CloseButton (line 10) | function CloseButton({ closeToast }: { closeToast: (event: React.MouseEv...

FILE: src/components/comment/Waline.tsx
  function Waline (line 5) | function Waline({ serverURL }: { serverURL: string }) {

FILE: src/components/footer/RunningDays.tsx
  function RunningDays (line 5) | function RunningDays() {

FILE: src/components/footer/ThemeSwitch.tsx
  function ThemeSwitch (line 4) | function ThemeSwitch() {

FILE: src/components/head-gradient/HeadGradient.tsx
  function HeadGradient (line 3) | function HeadGradient() {

FILE: src/components/head/WebAnalytics.tsx
  function WebAnalytics (line 3) | function WebAnalytics() {
  function UmamiAnalytics (line 19) | function UmamiAnalytics({
  function GoogleAnalytics (line 32) | function GoogleAnalytics({
  function MicrosoftClarity (line 52) | function MicrosoftClarity({

FILE: src/components/header/AnimatedLogo.tsx
  function AnimatedLogo (line 5) | function AnimatedLogo() {
  function Logo (line 24) | function Logo() {

FILE: src/components/header/BluredBackground.tsx
  function BluredBackground (line 3) | function BluredBackground() {

FILE: src/components/header/Header.tsx
  function Header (line 9) | function Header() {

FILE: src/components/header/HeaderContent.tsx
  function HeaderContent (line 13) | function HeaderContent() {
  function AnimatedMenu (line 22) | function AnimatedMenu() {
  function AccessibleMenu (line 37) | function AccessibleMenu() {
  function HeaderMenu (line 58) | function HeaderMenu({ isBgShow }: { isBgShow: boolean }) {
  function HeaderMenuItem (line 101) | function HeaderMenuItem({

FILE: src/components/header/HeaderDrawer.tsx
  function HeaderDrawer (line 37) | function HeaderDrawer({ zIndex = 999 }: { zIndex?: number }) {
  function DrawerContentImpl (line 102) | function DrawerContentImpl() {

FILE: src/components/header/HeaderMeta.tsx
  function HeaderMeta (line 5) | function HeaderMeta() {

FILE: src/components/header/SearchButton.tsx
  function loadPagefind (line 7) | async function loadPagefind() {
  function SearchButton (line 14) | function SearchButton() {
  function SearchPanel (line 37) | function SearchPanel() {
  function useSearchKeyboardEvents (line 175) | function useSearchKeyboardEvents({ onOpen }: { onOpen: () => void }) {

FILE: src/components/header/hooks.ts
  function useHeaderBgOpacity (line 15) | function useHeaderBgOpacity() {
  function useHasMetaInfo (line 26) | function useHasMetaInfo() {
  function useShouldHeaderMenuBgShow (line 30) | function useShouldHeaderMenuBgShow() {
  function useIsMobile (line 35) | function useIsMobile() {
  function useShouldHeaderMetaShow (line 39) | function useShouldHeaderMetaShow() {
  function useHeaderMetaInfo (line 46) | function useHeaderMetaInfo() {
  function usePathName (line 58) | function usePathName() {
  function useShouldAccessibleMenuShow (line 62) | function useShouldAccessibleMenuShow() {

FILE: src/components/hero/SocialList.tsx
  function SocialList (line 16) | function SocialList({ className }: { className?: string }) {

FILE: src/components/post/ActionAside.tsx
  type ShareData (line 10) | interface ShareData {
  function ActionAside (line 35) | function ActionAside() {
  function ShareButton (line 49) | function ShareButton() {
  function ShareModal (line 75) | function ShareModal({ url, text }: { url: string; text: string }) {
  function DonateButton (line 109) | function DonateButton() {
  function DonateContent (line 130) | function DonateContent() {

FILE: src/components/post/Outdate.tsx
  function Outdate (line 5) | function Outdate({ lastMod }: { lastMod: Date }) {

FILE: src/components/post/PostCardHoverOverlay.tsx
  function PostCardHoverOverlay (line 4) | function PostCardHoverOverlay() {

FILE: src/components/post/PostCopyright.tsx
  function getPostUrl (line 7) | function getPostUrl(slug: string) {
  function PostCopyright (line 11) | function PostCopyright({

FILE: src/components/post/PostToc.tsx
  function useActiveItem (line 7) | function useActiveItem() {
  function PostToc (line 33) | function PostToc({ headings }: { headings: MarkdownHeading[] }) {
  function TocItem (line 57) | function TocItem({

FILE: src/components/post/ReadingProgress.tsx
  function ReadingProgress (line 6) | function ReadingProgress() {

FILE: src/components/post/RelativeDate.tsx
  function RelativeDate (line 4) | function RelativeDate({ date }: { date: Date }) {

FILE: src/components/provider/HeaderMetaInfoProvider.tsx
  function HeaderMetaInfoProvider (line 5) | function HeaderMetaInfoProvider({

FILE: src/components/provider/PageScrollInfoProvider.tsx
  function PageScrollInfoProvider (line 6) | function PageScrollInfoProvider() {

FILE: src/components/provider/Provider.tsx
  function Provider (line 6) | function Provider(props: {

FILE: src/components/provider/ThemeProvider.tsx
  function ThemeProvider (line 6) | function ThemeProvider() {

FILE: src/components/provider/ViewportProvider.tsx
  function ViewportProvider (line 5) | function ViewportProvider() {

FILE: src/components/ui/modal/Modal.tsx
  function Modal (line 7) | function Modal({

FILE: src/components/ui/modal/ModalStack.tsx
  function ModalStack (line 6) | function ModalStack() {

FILE: src/components/ui/modal/hooks.ts
  type ModalProps (line 6) | type ModalProps = {
  function useModal (line 11) | function useModal() {
  function useCurrentModal (line 31) | function useCurrentModal() {

FILE: src/hooks/useDebounceValue.ts
  function useDebounceValue (line 3) | function useDebounceValue<T>(value: T, delay: number) {

FILE: src/pages/rss.xml.ts
  function GET (line 6) | async function GET(context: APIContext) {

FILE: src/plugins/rehypeCodeBlock.js
  function rehypeCodeBlock (line 4) | function rehypeCodeBlock() {

FILE: src/plugins/rehypeHeading.js
  function rehypeHeading (line 4) | function rehypeHeading() {

FILE: src/plugins/rehypeImage.js
  function rehypeImage (line 4) | function rehypeImage() {
  function buildImage (line 19) | function buildImage(node) {
  function buildFigure (line 25) | function buildFigure(node) {

FILE: src/plugins/rehypeLink.js
  function rehypeLink (line 4) | function rehypeLink() {

FILE: src/plugins/rehypeTableBlock.js
  function rehypeTableBlock (line 4) | function rehypeTableBlock() {

FILE: src/plugins/remarkEmbed.js
  function remarkEmbed (line 3) | function remarkEmbed() {

FILE: src/plugins/remarkReadingTime.js
  function remarkReadingTime (line 4) | function remarkReadingTime() {

FILE: src/plugins/remarkSpoiler.js
  function remarkSpoiler (line 3) | function remarkSpoiler() {
  function spoilerSyntax (line 14) | function spoilerSyntax() {
  function spoilerTokenize (line 25) | function spoilerTokenize(effects, ok, nok) {
  function markerTokenize (line 63) | function markerTokenize(effects, ok, nok) {
  function spoilerFromMarkdown (line 94) | function spoilerFromMarkdown() {

FILE: src/utils/content.ts
  function getAllPosts (line 4) | async function getAllPosts() {
  function getNewestPosts (line 13) | async function getNewestPosts() {
  function getOldestPosts (line 22) | async function getOldestPosts() {
  function getSortedPosts (line 31) | async function getSortedPosts() {
  function getAllPostsWordCount (line 44) | async function getAllPostsWordCount() {
  function slugify (line 61) | function slugify(text: string) {
  function getAllCategories (line 66) | async function getAllCategories() {
  function getAllTags (line 93) | async function getAllTags() {
  function getHotTags (line 120) | async function getHotTags(len = 5) {

FILE: src/utils/date.ts
  function getRelativeTime (line 2) | function getRelativeTime(startDate: Date, endDate = new Date()) {
  function getFormattedDate (line 26) | function getFormattedDate(date: Date) {
  function padZero (line 36) | function padZero(number: number, len = 2) {
  function getFormattedDateTime (line 41) | function getFormattedDateTime(date: Date) {
  function getDiffInDays (line 52) | function getDiffInDays(startDate: Date, endDate = new Date()) {
  function getShortDate (line 57) | function getShortDate(date: Date) {
  function getDaysInYear (line 65) | function getDaysInYear(date: Date) {
  function getStartOfYear (line 74) | function getStartOfYear(date: Date) {
  function getStartOfDay (line 80) | function getStartOfDay(date: Date) {

FILE: src/utils/theme.ts
  function changePageTheme (line 1) | function changePageTheme(theme: string) {
  function getSystemTheme (line 5) | function getSystemTheme() {
  function getLocalTheme (line 11) | function getLocalTheme() {
  function setLocalTheme (line 21) | function setLocalTheme(theme: string) {
Condensed preview — 131 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (160K chars).
[
  {
    "path": ".gitattributes",
    "chars": 18,
    "preview": "* text=auto eol=lf"
  },
  {
    "path": ".github/workflows/close-inactive-issues.yml",
    "chars": 816,
    "preview": "name: Close inactive issues\n\non:\n  schedule:\n    - cron: '30 1 * * *'\n\njobs:\n  close-issues:\n    if: github.repository ="
  },
  {
    "path": ".gitignore",
    "chars": 264,
    "preview": "# build output\ndist/\n\n# generated types\n.astro/\n\n# dependencies\nnode_modules/\n\n# logs\nnpm-debug.log*\nyarn-debug.log*\nyar"
  },
  {
    "path": ".prettierignore",
    "chars": 42,
    "preview": ".astro/\nnode_modules/\ndist/\npnpm-lock.yaml"
  },
  {
    "path": ".prettierrc",
    "chars": 217,
    "preview": "{\n  \"printWidth\": 100,\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"plugins\": [\"prettier-plugin-astro\"],\n  \"overrides\": [\n"
  },
  {
    "path": ".vscode/extensions.json",
    "chars": 87,
    "preview": "{\n  \"recommendations\": [\"astro-build.astro-vscode\"],\n  \"unwantedRecommendations\": []\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "chars": 207,
    "preview": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"command\": \"./node_modules/.bin/astro dev\",\n      \"name\": \"Dev"
  },
  {
    "path": "LICENSE",
    "chars": 1064,
    "preview": "MIT License\n\nCopyright (c) 2024 柃夏chapu\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof"
  },
  {
    "path": "README.md",
    "chars": 1602,
    "preview": "# Gyoza\n\nGyoza is a static blog template built with Astro and React.\n\n![astro version](https://img.shields.io/badge/astr"
  },
  {
    "path": "astro.config.js",
    "chars": 1829,
    "preview": "import { defineConfig } from 'astro/config'\nimport { remarkReadingTime } from './src/plugins/remarkReadingTime'\nimport {"
  },
  {
    "path": "commitlint.config.js",
    "chars": 67,
    "preview": "export default {\n  extends: ['@commitlint/config-conventional'],\n}\n"
  },
  {
    "path": "package.json",
    "chars": 2084,
    "preview": "{\n  \"name\": \"astro-gyoza\",\n  \"type\": \"module\",\n  \"version\": \"0.0.1\",\n  \"scripts\": {\n    \"prepare\": \"pnpm exec simple-git"
  },
  {
    "path": "scripts/new-friend.js",
    "chars": 952,
    "preview": "import { input } from '@inquirer/prompts'\nimport fs from 'fs'\nimport path from 'path'\nimport { isFileNameSafe } from './"
  },
  {
    "path": "scripts/new-post.js",
    "chars": 802,
    "preview": "import { input } from '@inquirer/prompts'\nimport fs from 'fs'\nimport path from 'path'\nimport { isFileNameSafe } from './"
  },
  {
    "path": "scripts/new-project.js",
    "chars": 961,
    "preview": "import { input } from '@inquirer/prompts'\nimport fs from 'fs'\nimport path from 'path'\nimport { isFileNameSafe } from './"
  },
  {
    "path": "scripts/utils.js",
    "chars": 96,
    "preview": "export function isFileNameSafe(fileName) {\n  return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(fileName)\n}\n"
  },
  {
    "path": "src/components/AnimatedSignature.tsx",
    "chars": 222,
    "preview": "import Svg from '@/assets/signature.svg?raw'\n\nexport function AnimatedSignature() {\n  return (\n    <div\n      className="
  },
  {
    "path": "src/components/BackToTopFAB.tsx",
    "chars": 999,
    "preview": "import { useAtomValue } from 'jotai'\nimport { pageScrollLocationAtom } from '@/store/scrollInfo'\nimport { AnimatePresenc"
  },
  {
    "path": "src/components/CategoryList.astro",
    "chars": 812,
    "preview": "---\ninterface Props {\n  categories: {\n    name: string\n    slug: string\n    count: number\n  }[]\n}\n\nconst { categories } "
  },
  {
    "path": "src/components/Flashlight.tsx",
    "chars": 954,
    "preview": "import { useLayoutEffect, useState } from 'react'\n\nexport function Flashlight() {\n  const [cursorX, setCursorX] = useSta"
  },
  {
    "path": "src/components/FriendList.astro",
    "chars": 1009,
    "preview": "---\nimport { getCollection } from 'astro:content'\n\nconst friends = await getCollection('friends')\n---\n\n<ul class=\"grid g"
  },
  {
    "path": "src/components/Highlight.astro",
    "chars": 248,
    "preview": "---\ninterface Props {\n  class?: string\n}\n\nconst { class: className } = Astro.props\n---\n\n<span class=\"relative\" class:lis"
  },
  {
    "path": "src/components/MarkdownWrapper.astro",
    "chars": 184,
    "preview": "---\ninterface Props {\n  class?: string\n}\n\nconst { class: className } = Astro.props\n---\n\n<article id=\"markdown-wrapper\" c"
  },
  {
    "path": "src/components/ProjectList.astro",
    "chars": 1105,
    "preview": "---\nimport { getCollection } from 'astro:content'\n\nconst projects = await getCollection('projects')\n---\n\n<ul class=\"grid"
  },
  {
    "path": "src/components/RootPortal.tsx",
    "chars": 200,
    "preview": "import { createPortal } from 'react-dom'\n\nexport function RootPortal({\n  to = document.body,\n  children,\n}: {\n  to?: HTM"
  },
  {
    "path": "src/components/SectionBlock.astro",
    "chars": 190,
    "preview": "---\ninterface Props {\n  title: string\n}\n\nconst { title } = Astro.props\n---\n\n<section>\n  <div class=\"mb-8 font-bold upper"
  },
  {
    "path": "src/components/TagList.astro",
    "chars": 738,
    "preview": "---\ninterface Props {\n  tags: {\n    name: string\n    slug: string\n    count?: number\n  }[]\n}\n\nconst { tags } = Astro.pro"
  },
  {
    "path": "src/components/Timeline.astro",
    "chars": 1595,
    "preview": "---\nimport type { CollectionEntry } from 'astro:content'\nimport { getShortDate } from '@/utils/date'\n\ninterface Props {\n"
  },
  {
    "path": "src/components/TimelineProgress.tsx",
    "chars": 1838,
    "preview": "import { useEffect, useRef, useState } from 'react'\nimport { animate } from 'framer-motion'\nimport { getDaysInYear, getD"
  },
  {
    "path": "src/components/ToastContainer.tsx",
    "chars": 640,
    "preview": "import { ToastContainer as ReactToastContainer } from 'react-toastify'\nimport 'react-toastify/dist/ReactToastify.css';\n\n"
  },
  {
    "path": "src/components/comment/Comments.astro",
    "chars": 157,
    "preview": "---\nimport { Waline } from './Waline'\nimport { waline } from '@/config.json'\n---\n\n<div>\n  {waline.serverURL && <Waline {"
  },
  {
    "path": "src/components/comment/Waline.tsx",
    "chars": 697,
    "preview": "import { useEffect, useRef } from 'react'\nimport { init } from '@waline/client'\nimport '@waline/client/style'\n\nexport fu"
  },
  {
    "path": "src/components/comment/index.ts",
    "chars": 55,
    "preview": "export { default as Comments } from './Comments.astro'\n"
  },
  {
    "path": "src/components/footer/Footer.astro",
    "chars": 1470,
    "preview": "---\nimport { ThemeSwitch } from './ThemeSwitch'\nimport { author, footer } from '@/config.json'\nimport { getAllPostsWordC"
  },
  {
    "path": "src/components/footer/Link.astro",
    "chars": 379,
    "preview": "---\nimport type { HTMLAttributes } from 'astro/types'\n\ninterface Props extends HTMLAttributes<'a'> {\n  href: string\n}\n\nc"
  },
  {
    "path": "src/components/footer/RunningDays.tsx",
    "chars": 426,
    "preview": "import { useLayoutEffect, useState } from 'react'\nimport { footer } from '@/config.json'\nimport { getDiffInDays } from '"
  },
  {
    "path": "src/components/footer/ThemeSwitch.tsx",
    "chars": 1436,
    "preview": "import { themeAtom } from '@/store/theme'\nimport { useAtom } from 'jotai'\n\nexport function ThemeSwitch() {\n  const [them"
  },
  {
    "path": "src/components/head/AccentColorInjector.astro",
    "chars": 1805,
    "preview": "<script>\n  import chroma from 'chroma-js'\n  import { color as themeColor } from '@/config.json'\n\n  function pickRandomAc"
  },
  {
    "path": "src/components/head/CommonHead.astro",
    "chars": 1650,
    "preview": "---\nimport { site, author } from '@/config.json'\n\ninterface Props {\n  title?: string\n  description?: string\n  image?: st"
  },
  {
    "path": "src/components/head/PrintVersion.astro",
    "chars": 274,
    "preview": "<script>\n  import { version } from '@/../package.json'\n  \n  console.log(\n    `%c Gyoza ${version} %c https://gyoza.lxcha"
  },
  {
    "path": "src/components/head/ThemeLoader.astro",
    "chars": 731,
    "preview": "<script>\n  import { getLocalTheme, getSystemTheme, changePageTheme } from '@/utils/theme'\n\n  // 哀悼日\n  const MOURNING_DAY"
  },
  {
    "path": "src/components/head/WebAnalytics.tsx",
    "chars": 1635,
    "preview": "import { analytics } from '@/config.json'\n\nexport function WebAnalytics() {\n  if (import.meta.env.DEV || !analytics.enab"
  },
  {
    "path": "src/components/head/index.ts",
    "chars": 306,
    "preview": "export { default as AccentColorInjector } from './AccentColorInjector.astro'\nexport { default as CommonHead } from './Co"
  },
  {
    "path": "src/components/head-gradient/HeadGradient.tsx",
    "chars": 371,
    "preview": "import { motion } from 'framer-motion'\n\nexport function HeadGradient() {\n  return (\n    <motion.div\n      className=\"abs"
  },
  {
    "path": "src/components/head-gradient/index.ts",
    "chars": 46,
    "preview": "export { HeadGradient } from './HeadGradient'\n"
  },
  {
    "path": "src/components/header/AnimatedLogo.tsx",
    "chars": 815,
    "preview": "import { AnimatePresence, motion } from 'framer-motion'\nimport { useShouldHeaderMetaShow, useIsMobile } from './hooks'\ni"
  },
  {
    "path": "src/components/header/BluredBackground.tsx",
    "chars": 351,
    "preview": "import { useHeaderBgOpacity } from './hooks'\n\nexport function BluredBackground() {\n  const opacity = useHeaderBgOpacity("
  },
  {
    "path": "src/components/header/Header.tsx",
    "chars": 1026,
    "preview": "import { BluredBackground } from './BluredBackground'\nimport { HeaderContent } from './HeaderContent'\nimport { SearchBut"
  },
  {
    "path": "src/components/header/HeaderContent.tsx",
    "chars": 3631,
    "preview": "import { useState } from 'react'\nimport { menus } from '@/config.json'\nimport { clsx } from 'clsx'\nimport { AnimatePrese"
  },
  {
    "path": "src/components/header/HeaderDrawer.tsx",
    "chars": 3223,
    "preview": "import { menus } from '@/config.json'\nimport { createContext, useContext, useState, forwardRef } from 'react'\nimport * a"
  },
  {
    "path": "src/components/header/HeaderMeta.tsx",
    "chars": 1147,
    "preview": "import { site } from '@/config.json'\nimport { AnimatePresence, motion } from 'framer-motion'\nimport { useHeaderMetaInfo,"
  },
  {
    "path": "src/components/header/SearchButton.tsx",
    "chars": 6284,
    "preview": "import { motion } from 'framer-motion'\nimport { useCurrentModal, useModal } from '@/components/ui/modal'\nimport { useEff"
  },
  {
    "path": "src/components/header/hooks.ts",
    "chars": 1669,
    "preview": "import { useAtomValue } from 'jotai'\nimport {\n  pathNameAtom,\n  metaTitleAtom,\n  metaDescriptionAtom,\n  metaSlugAtom,\n  "
  },
  {
    "path": "src/components/hero/Hero.astro",
    "chars": 1317,
    "preview": "---\nimport { hero, author } from '@/config.json'\nimport { SocialList } from './SocialList'\nimport Highlight from '@/comp"
  },
  {
    "path": "src/components/hero/SocialList.tsx",
    "chars": 1218,
    "preview": "import clsx from 'clsx'\nimport { hero } from '@/config.json'\nimport { motion } from 'framer-motion'\n\nconst itemVariants "
  },
  {
    "path": "src/components/post/ActionAside.tsx",
    "chars": 3909,
    "preview": "import { sponsor, site } from '@/config.json'\nimport { motion } from 'framer-motion'\nimport * as QR from 'qrcode.react'\n"
  },
  {
    "path": "src/components/post/Outdate.tsx",
    "chars": 873,
    "preview": "import { useEffect, useState } from 'react'\nimport { getDiffInDays, getFormattedDate } from '@/utils/date'\nimport { moti"
  },
  {
    "path": "src/components/post/PostArchiveInfo.astro",
    "chars": 1000,
    "preview": "---\nimport { slugify } from '@/utils/content'\n\ninterface Props {\n  tags: string[]\n  category?: string\n  class?: string\n}"
  },
  {
    "path": "src/components/post/PostCard.astro",
    "chars": 2311,
    "preview": "---\nimport type { CollectionEntry } from 'astro:content'\nimport { PostCardHoverOverlay } from './PostCardHoverOverlay'\ni"
  },
  {
    "path": "src/components/post/PostCardHoverOverlay.tsx",
    "chars": 1614,
    "preview": "import { AnimatePresence, motion } from 'framer-motion'\nimport { useEffect, useRef, useState } from 'react'\n\nexport func"
  },
  {
    "path": "src/components/post/PostCopyright.tsx",
    "chars": 1689,
    "preview": "import { author, site } from '@/config.json'\nimport { getFormattedDateTime } from '@/utils/date'\nimport { AnimatedSignat"
  },
  {
    "path": "src/components/post/PostList.astro",
    "chars": 344,
    "preview": "---\nimport type { CollectionEntry } from 'astro:content'\nimport PostCard from './PostCard.astro'\n\ninterface Props {\n  po"
  },
  {
    "path": "src/components/post/PostMetaInfo.astro",
    "chars": 684,
    "preview": "---\nimport { RelativeDate } from './RelativeDate'\n\ninterface Props {\n  date: Date\n  lastMod?: Date\n  words: number\n  rea"
  },
  {
    "path": "src/components/post/PostNav.astro",
    "chars": 825,
    "preview": "---\nimport type { CollectionEntry } from 'astro:content'\n\ninterface Props {\n  prev?: CollectionEntry<'posts'>\n  next?: C"
  },
  {
    "path": "src/components/post/PostPagination.astro",
    "chars": 1678,
    "preview": "---\ninterface Props {\n  current: number\n  total: number\n  getPageUrl: (page: number) => string\n}\n\nconst { current, total"
  },
  {
    "path": "src/components/post/PostToc.tsx",
    "chars": 3167,
    "preview": "import { pageScrollLocationAtom, pageScrollDirectionAtom } from '@/store/scrollInfo'\nimport type { MarkdownHeading } fro"
  },
  {
    "path": "src/components/post/ReadingProgress.tsx",
    "chars": 793,
    "preview": "import { useEffect, useState } from 'react'\nimport { useAtomValue } from 'jotai'\nimport { pageScrollLocationAtom } from "
  },
  {
    "path": "src/components/post/RelativeDate.tsx",
    "chars": 397,
    "preview": "import { getRelativeTime, getFormattedDate } from '@/utils/date'\nimport { useEffect, useState } from 'react'\n\nexport fun"
  },
  {
    "path": "src/components/provider/HeaderMetaInfoProvider.tsx",
    "chars": 845,
    "preview": "import { useSetAtom } from 'jotai'\nimport { useEffect } from 'react'\nimport { pathNameAtom, metaTitleAtom, metaDescripti"
  },
  {
    "path": "src/components/provider/PageScrollInfoProvider.tsx",
    "chars": 1167,
    "preview": "import { useLayoutEffect, useRef } from 'react'\nimport { throttle } from 'lodash-es'\nimport { useSetAtom } from 'jotai'\n"
  },
  {
    "path": "src/components/provider/Provider.tsx",
    "chars": 509,
    "preview": "import { HeaderMetaInfoProvider } from './HeaderMetaInfoProvider'\nimport { PageScrollInfoProvider } from './PageScrollIn"
  },
  {
    "path": "src/components/provider/ThemeProvider.tsx",
    "chars": 908,
    "preview": "import { useAtomValue } from 'jotai'\nimport { useEffect } from 'react'\nimport { getSystemTheme, changePageTheme, setLoca"
  },
  {
    "path": "src/components/provider/ViewportProvider.tsx",
    "chars": 565,
    "preview": "import { useSetAtom } from 'jotai'\nimport { useEffect } from 'react'\nimport { isMobileAtom } from '@/store/viewport'\n\nex"
  },
  {
    "path": "src/components/ui/modal/Modal.tsx",
    "chars": 1592,
    "preview": "import { modalStackAtom } from '@/store/modalStack'\nimport { useSetAtom } from 'jotai'\nimport * as Dialog from '@radix-u"
  },
  {
    "path": "src/components/ui/modal/ModalStack.tsx",
    "chars": 468,
    "preview": "import { useAtomValue } from 'jotai'\nimport { Modal } from './Modal'\nimport { modalStackAtom } from '@/store/modalStack'"
  },
  {
    "path": "src/components/ui/modal/context.ts",
    "chars": 128,
    "preview": "import { createContext } from 'react'\n\nexport const CurrentModalContext = createContext<{\n  dismiss: () => void\n}>(null "
  },
  {
    "path": "src/components/ui/modal/hooks.ts",
    "chars": 834,
    "preview": "import { useContext, useId, useRef } from 'react'\nimport { useSetAtom } from 'jotai'\nimport { modalStackAtom } from '@/s"
  },
  {
    "path": "src/components/ui/modal/index.ts",
    "chars": 53,
    "preview": "export * from './hooks'\nexport * from './ModalStack'\n"
  },
  {
    "path": "src/config.json",
    "chars": 2916,
    "preview": "{\n  \"site\": {\n    \"url\": \"https://gyoza.lxchapu.com\",\n    \"title\": \"Gyoza\",\n    \"description\": \"这是一个使用 Astro 和 React 开发的"
  },
  {
    "path": "src/content/config.ts",
    "chars": 1208,
    "preview": "import { z, defineCollection } from 'astro:content'\n\nconst postsCollection = defineCollection({\n  type: 'content',\n  sch"
  },
  {
    "path": "src/content/friends/Keigo.yml",
    "chars": 229,
    "preview": "title: Keigo\ndescription: 那天早上的霧散了,不止早上,不止霧\nlink: https://astro.sliverkeigo.top/\navatar: https://www.sliverkeigo.top/_ne"
  },
  {
    "path": "src/content/friends/astro-docs.yaml",
    "chars": 152,
    "preview": "title: Astro Docs\ndescription: Astro 入门指南\nlink: https://docs.astro.build/en/getting-started/\navatar: https://s2.loli.net"
  },
  {
    "path": "src/content/friends/lxchapu.yaml",
    "chars": 137,
    "preview": "title: '柃夏chapu'\ndescription: '生活明朗,万物可爱。'\nlink: 'https://www.lxchapu.com'\navatar: 'https://s2.loli.net/2024/04/23/tIxXm"
  },
  {
    "path": "src/content/posts/embed.md",
    "chars": 393,
    "preview": "---\ntitle: 在文章中嵌入视频和代码\ndate: 2024-04-04\nlastMod: 2024-05-18T07:29:49.820Z\ntags: [Video, Markdown]\ncategory: 例子\nsummary: "
  },
  {
    "path": "src/content/posts/guide.md",
    "chars": 2864,
    "preview": "---\ntitle: Gyoza 使用指南\ndate: 2024-04-01\nlastMod: 2024-08-10T03:58:16.758Z\nsummary: 欢迎使用 Gyoza,Gyoza 是一款 Astro 博客主题,它保持简洁和"
  },
  {
    "path": "src/content/posts/how-to-use-icons.md",
    "chars": 1518,
    "preview": "---\ntitle: 如何在 Gyoza 中使用图标?\ndate: 2024-05-08T10:54:27.000Z\ntags: [Icon]\ncategory: 教程\ncomments: true\ndraft: false\n---\n\nGy"
  },
  {
    "path": "src/content/posts/markdown.md",
    "chars": 6027,
    "preview": "---\ntitle: Markdown 示例\ndate: 2024-04-01\nsummary: 这是一篇 Markdown 文章的示例。展示了 Markdown 的语法和渲染效果。\ncategory: 例子\ntags: [Markdown"
  },
  {
    "path": "src/content/projects/gyoza.yaml",
    "chars": 167,
    "preview": "title: Gyoza\ndescription: Gyoza 是一个使用 Astro 和 React 开发的响应式博客主题\nimage: https://s2.loli.net/2024/03/02/hba5MAilRXguZtr.web"
  },
  {
    "path": "src/content/spec/about.md",
    "chars": 715,
    "preview": "---\ntitle: 自述\ndescription: 这是一份站长的自述报告,请查收。\ncomments: false\n---\n\n## 关于 Gyoza\n\nGyoza 是一个使用 Astro 和 React 开发的博客主题。Gyoza 借鉴"
  },
  {
    "path": "src/content/spec/friends.md",
    "chars": 339,
    "preview": "---\ntitle: 朋友们\ndescription: 我的小伙伴们和一些有趣的站点。\ncomments: true\n---\n\n## 怎么申请友链?\n\n想要交换友链的小伙伴们,欢迎去本站的 [Github 仓库](https://githu"
  },
  {
    "path": "src/content/spec/projects.md",
    "chars": 78,
    "preview": "---\ntitle: 项目\ndescription: 这些是我创建或参与的项目,如果你感兴趣不妨去给个 Star。\ncomments: false\n---\n"
  },
  {
    "path": "src/env.d.ts",
    "chars": 85,
    "preview": "/// <reference path=\"../.astro/types.d.ts\" />\n/// <reference types=\"astro/client\" />\n"
  },
  {
    "path": "src/hooks/useDebounceValue.ts",
    "chars": 377,
    "preview": "import { useEffect, useState } from 'react'\n\nexport function useDebounceValue<T>(value: T, delay: number) {\n  const [deb"
  },
  {
    "path": "src/layouts/Layout.astro",
    "chars": 879,
    "preview": "---\nimport {\n  CommonHead,\n  WebAnalytics,\n  ThemeLoader,\n  AccentColorInjector,\n  PrintVersion,\n} from '@/components/he"
  },
  {
    "path": "src/layouts/MarkdownLayout.astro",
    "chars": 951,
    "preview": "---\nimport Layout from './Layout.astro'\nimport { HeadGradient } from '@/components/head-gradient'\nimport Footer from '@/"
  },
  {
    "path": "src/layouts/PageLayout.astro",
    "chars": 656,
    "preview": "---\nimport Layout from './Layout.astro'\nimport { Header } from '@/components/header/Header'\nimport Footer from '@/compon"
  },
  {
    "path": "src/pages/404.astro",
    "chars": 616,
    "preview": "---\nimport Layout from '@/layouts/Layout.astro'\nimport { Flashlight } from '@/components/Flashlight'\n---\n\n<Layout title="
  },
  {
    "path": "src/pages/[...page].astro",
    "chars": 2675,
    "preview": "---\nimport type { GetStaticPaths } from 'astro'\nimport PageLayout from '@/layouts/PageLayout.astro'\nimport PostList from"
  },
  {
    "path": "src/pages/[spec].astro",
    "chars": 3033,
    "preview": "---\nimport type { GetStaticPaths } from 'astro'\nimport type { CollectionEntry } from 'astro:content'\nimport { getCollect"
  },
  {
    "path": "src/pages/archives.astro",
    "chars": 723,
    "preview": "---\nimport Timeline from '@/components/Timeline.astro'\nimport Highlight from '@/components/Highlight.astro'\nimport PageL"
  },
  {
    "path": "src/pages/categories/[category].astro",
    "chars": 1194,
    "preview": "---\nimport type { GetStaticPaths } from 'astro'\nimport PageLayout from '@/layouts/PageLayout.astro'\nimport { getAllCateg"
  },
  {
    "path": "src/pages/posts/[...slug].astro",
    "chars": 3628,
    "preview": "---\nimport type { CollectionEntry } from 'astro:content'\nimport type { GetStaticPaths } from 'astro'\nimport MarkdownLayo"
  },
  {
    "path": "src/pages/robots.txt.ts",
    "chars": 351,
    "preview": "import type { APIRoute } from 'astro'\n\nconst robotsTxt = `\nUser-agent: *\nAllow: /\n\nDisallow: /_astro/\nDisallow: /fonts/\n"
  },
  {
    "path": "src/pages/rss.xml.ts",
    "chars": 594,
    "preview": "import type { APIContext } from 'astro'\nimport rss from '@astrojs/rss'\nimport { site } from '@/config.json'\nimport { get"
  },
  {
    "path": "src/pages/tags/[tag].astro",
    "chars": 1119,
    "preview": "---\nimport type { GetStaticPaths } from 'astro'\nimport { getAllTags, getOldestPosts, slugify } from '@/utils/content'\nim"
  },
  {
    "path": "src/pages/tags/index.astro",
    "chars": 545,
    "preview": "---\nimport TagList from '@/components/TagList.astro'\nimport Highlight from '@/components/Highlight.astro'\nimport { getAl"
  },
  {
    "path": "src/plugins/rehypeCodeBlock.js",
    "chars": 831,
    "preview": "import { h } from 'hastscript'\nimport { visit } from 'unist-util-visit'\n\nexport function rehypeCodeBlock() {\n  return fu"
  },
  {
    "path": "src/plugins/rehypeCodeHighlight.js",
    "chars": 208,
    "preview": "import rehypeShiki from '@shikijs/rehype'\n\nexport const rehypeCodeHighlight = [\n  rehypeShiki,\n  {\n    themes: {\n      l"
  },
  {
    "path": "src/plugins/rehypeHeading.js",
    "chars": 833,
    "preview": "import { h } from 'hastscript'\nimport { visit } from 'unist-util-visit'\n\nexport function rehypeHeading() {\n  return (tre"
  },
  {
    "path": "src/plugins/rehypeImage.js",
    "chars": 842,
    "preview": "import { h } from 'hastscript'\nimport { visit } from 'unist-util-visit'\n\nexport function rehypeImage() {\n  return functi"
  },
  {
    "path": "src/plugins/rehypeLink.js",
    "chars": 589,
    "preview": "import { h } from 'hastscript'\nimport { visit } from 'unist-util-visit'\n\nexport function rehypeLink() {\n  return (tree) "
  },
  {
    "path": "src/plugins/rehypeTableBlock.js",
    "chars": 600,
    "preview": "import { h } from 'hastscript'\nimport { visit } from 'unist-util-visit'\n\nexport function rehypeTableBlock() {\n  return f"
  },
  {
    "path": "src/plugins/remarkEmbed.js",
    "chars": 1641,
    "preview": "import { visit } from 'unist-util-visit'\n\nexport function remarkEmbed() {\n  return function (tree) {\n    visit(tree, (no"
  },
  {
    "path": "src/plugins/remarkReadingTime.js",
    "chars": 378,
    "preview": "import getReadingTime from 'reading-time'\nimport { toString } from 'mdast-util-to-string'\n\nexport function remarkReading"
  },
  {
    "path": "src/plugins/remarkSpoiler.js",
    "chars": 2548,
    "preview": "import { codes, types, constants } from 'micromark-util-symbol'\n\nexport function remarkSpoiler() {\n  const self = this\n "
  },
  {
    "path": "src/store/metaInfo.ts",
    "chars": 411,
    "preview": "import { atom } from 'jotai'\n\nexport const pathNameAtom = atom('')\nexport const metaTitleAtom = atom('')\nexport const me"
  },
  {
    "path": "src/store/modalStack.ts",
    "chars": 126,
    "preview": "import { atom } from 'jotai'\n\nexport const modalStackAtom = atom<\n  {\n    id: string\n    content: React.ReactNode\n  }[]\n"
  },
  {
    "path": "src/store/scrollInfo.ts",
    "chars": 148,
    "preview": "import { atom } from 'jotai'\n\nexport const pageScrollLocationAtom = atom(0)\nexport const pageScrollDirectionAtom = atom<"
  },
  {
    "path": "src/store/theme.ts",
    "chars": 123,
    "preview": "import { getLocalTheme } from '@/utils/theme'\nimport { atom } from 'jotai'\n\nexport const themeAtom = atom(getLocalTheme("
  },
  {
    "path": "src/store/viewport.ts",
    "chars": 70,
    "preview": "import { atom } from 'jotai'\n\nexport const isMobileAtom = atom(false)\n"
  },
  {
    "path": "src/styles/global.css",
    "chars": 812,
    "preview": "@import './iconfont.css';\n@import './shiki.css';\n@import './markdown.css';\n@import './signature.css';\n@import './swup.cs"
  },
  {
    "path": "src/styles/iconfont.css",
    "chars": 3179,
    "preview": "@font-face {\n  font-family: 'iconfont';\n  /* Project id 4528205 */\n  src:\n    url('/fonts/iconfont.woff2?t=1716210197380"
  },
  {
    "path": "src/styles/markdown.css",
    "chars": 2618,
    "preview": ".markdown > :first-child {\n  @apply mt-0;\n}\n\n.markdown > :last-child {\n  @apply mb-0;\n}\n\n.markdown p {\n  @apply mb-5;\n}\n"
  },
  {
    "path": "src/styles/shiki.css",
    "chars": 146,
    "preview": ".shiki,\n.shiki span {\n  color: var(--shiki-light);\n}\n\n[data-theme='dark'] .shiki,\n[data-theme='dark'] .shiki span {\n  co"
  },
  {
    "path": "src/styles/signature.css",
    "chars": 475,
    "preview": ".animated-signature path {\n  stroke-dasharray: 2400;\n  stroke-dashoffset: 2400;\n  fill: transparent;\n  animation: drawSi"
  },
  {
    "path": "src/styles/swup.css",
    "chars": 135,
    "preview": "html.is-changing .swup-transition-fade {\n  transition: 0.4s;\n  opacity: 1;\n}\nhtml.is-animating .swup-transition-fade {\n "
  },
  {
    "path": "src/utils/content.ts",
    "chars": 2850,
    "preview": "import { getCollection } from 'astro:content'\n\n// 获取所有文章\nasync function getAllPosts() {\n  const allPosts = await getColl"
  },
  {
    "path": "src/utils/date.ts",
    "chars": 2193,
    "preview": "// 获取两个日期的相对时间\nexport function getRelativeTime(startDate: Date, endDate = new Date()) {\n  const diffSeconds = Math.floor"
  },
  {
    "path": "src/utils/theme.ts",
    "chars": 569,
    "preview": "export function changePageTheme(theme: string) {\n  document.documentElement.setAttribute('data-theme', theme)\n}\n\nexport "
  },
  {
    "path": "tailwind.config.ts",
    "chars": 1526,
    "preview": "import type { Config } from 'tailwindcss'\n\nconst config: Config = {\n  content: ['./src/**/*.{astro,ts,tsx,js,jsx}'],\n  d"
  },
  {
    "path": "tsconfig.json",
    "chars": 247,
    "preview": "{\n  \"extends\": \"astro/tsconfigs/strict\",\n  \"compilerOptions\": {\n    \"strictNullChecks\": true,\n    \"baseUrl\": \".\",\n    \"p"
  }
]

About this extraction

This page contains the full source code of the lxchapu/astro-gyoza GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 131 files (135.3 KB), approximately 43.0k tokens, and a symbol index with 109 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!