This commit is contained in:
2025-07-18 21:37:37 +08:00
commit a009f760d6
114 changed files with 17824 additions and 0 deletions

22
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
groups:
patch-updates:
patterns:
- "*"
update-types:
- "patch"
minor-updates:
patterns:
- "*"
update-types:
- "minor"
pull-request-branch-name:
separator: "-"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]

20
.github/workflows/biome.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: Code quality
on:
push:
branches: [ main ] # Adjust branches as needed
pull_request:
branches: [ main ] # Adjust branches as needed
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Biome
uses: biomejs/setup-biome@f382a98e582959e6aaac8e5f8b17b31749018780 # v2.5.0
with:
version: latest
- name: Run Biome
run: biome ci ./src --reporter=github

67
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Build and Check
on:
push:
branches: [ main ] # Adjust branches as needed
pull_request:
branches: [ main ] # Adjust branches as needed
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
check:
strategy:
matrix:
node: [ 22, 23 ]
runs-on: ubuntu-latest
name: Astro Check for Node.js ${{ matrix.node }}
steps:
- name: Setup Node.js
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: ${{ matrix.node }} # Use LTS
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
run_install: false # Disable auto-install
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Astro Check
run: pnpm astro check
build:
strategy:
matrix:
node: [ 22, 23 ]
runs-on: ubuntu-latest
name: Astro Build for Node.js ${{ matrix.node }} # Corrected job name
steps:
- name: Setup Node.js
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: ${{ matrix.node }}
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
run_install: false # Disable auto-install
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Astro Build
run: pnpm astro build

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# 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
.vercel
package-lock.json
bun.lockb
yarn.lock

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
manage-package-manager-versions = true

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome", "astro-build.astro-vscode"]
}

22
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"quickfix.biome": "always",
"source.organizeImports.biome": "always"
},
"frontMatter.dashboard.openOnStart": false
}

21
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,21 @@
# Contributing
Thank you for your interest in contributing!
## Before You Start
If you plan to make major changes (especially new features or design changes), please open an issue or discussion before starting work. This helps ensure your effort aligns with the project's direction.
## Submitting Code
Please keep each pull request focused on a single purpose. Avoid mixing unrelated changes in one PR, as this can make reviewing and merging code more difficult.
Please use the [Conventional Commits](https://www.conventionalcommits.org/) format for your commit messages whenever possible. This keeps our history clear and consistent.
Before submitting code, please run the appropriate commands to check for errors and format your code.
```bash
pnpm check
pnpm format
```

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 saicaca
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.

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
# 🍥Fuwari
![Node.js >= 20](https://img.shields.io/badge/node.js-%3E%3D20-brightgreen)
![pnpm >= 9](https://img.shields.io/badge/pnpm-%3E%3D9-blue)
[![DeepWiki](https://img.shields.io/badge/DeepWiki-saicaca%2Ffuwari-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/saicaca/fuwari)
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fsaicaca%2Ffuwari.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fsaicaca%2Ffuwari?ref=badge_shield&issueType=license)
A static blog template built with [Astro](https://astro.build).
[**🖥️ Live Demo (Vercel)**](https://fuwari.vercel.app)
![Preview Image](https://raw.githubusercontent.com/saicaca/resource/main/fuwari/home.png)
🌏 README in
[**中文**](https://github.com/saicaca/fuwari/blob/main/docs/README.zh-CN.md) /
[**日本語**](https://github.com/saicaca/fuwari/blob/main/docs/README.ja.md) /
[**한국어**](https://github.com/saicaca/fuwari/blob/main/docs/README.ko.md) /
[**Español**](https://github.com/saicaca/fuwari/blob/main/docs/README.es.md) /
[**ไทย**](https://github.com/saicaca/fuwari/blob/main/docs/README.th.md) (Provided by the community and may not always be up-to-date)
## ✨ Features
- [x] Built with [Astro](https://astro.build) and [Tailwind CSS](https://tailwindcss.com)
- [x] Smooth animations and page transitions
- [x] Light / dark mode
- [x] Customizable theme colors & banner
- [x] Responsive design
- [x] Search functionality with [Pagefind](https://pagefind.app/)
- [x] [Markdown extended features](https://github.com/saicaca/fuwari?tab=readme-ov-file#-markdown-extended-syntax)
- [x] Table of contents
- [x] RSS feed
## 🚀 Getting Started
1. Create your blog repository:
- [Generate a new repository](https://github.com/saicaca/fuwari/generate) from this template or fork this repository.
- Or run one of the following commands:
```sh
npm create fuwari@latest
yarn create fuwari
pnpm create fuwari@latest
bun create fuwari@latest
deno run -A npm:create-fuwari@latest
```
2. To edit your blog locally, clone your repository, run `pnpm install` to install dependencies.
- Install [pnpm](https://pnpm.io) `npm install -g pnpm` if you haven't.
3. Edit the config file `src/config.ts` to customize your blog.
4. Run `pnpm new-post <filename>` to create a new post and edit it in `src/content/posts/`.
5. Deploy your blog to Vercel, Netlify, GitHub Pages, etc. following [the guides](https://docs.astro.build/en/guides/deploy/). You need to edit the site configuration in `astro.config.mjs` before deployment.
## 📝 Frontmatter of Posts
```yaml
---
title: My First Blog Post
published: 2023-09-09
description: This is the first post of my new Astro blog.
image: ./cover.jpg
tags: [Foo, Bar]
category: Front-end
draft: false
lang: jp # Set only if the post's language differs from the site's language in `config.ts`
---
```
## 🧩 Markdown Extended Syntax
In addition to Astro's default support for [GitHub Flavored Markdown](https://github.github.com/gfm/), several extra Markdown features are included:
- Admonitions ([Preview and Usage](https://fuwari.vercel.app/posts/markdown-extended/#admonitions))
- GitHub repository cards ([Preview and Usage](https://fuwari.vercel.app/posts/markdown-extended/#github-repository-cards))
- Enhanced code blocks with Expressive Code ([Preview](https://fuwari.vercel.app/posts/expressive-code/) / [Docs](https://expressive-code.com/))
## ⚡ Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
|:---------------------------|:----------------------------------------------------|
| `pnpm install` | 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 check` | Run checks for errors in your code |
| `pnpm format` | Format your code using Biome |
| `pnpm new-post <filename>` | Create a new post |
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
| `pnpm astro --help` | Get help using the Astro CLI |
## ✏️ Contributing
Check out the [Contributing Guide](https://github.com/saicaca/fuwari/blob/main/CONTRIBUTING.md) for details on how to contribute to this project.
## 📄 License
This project is licensed under the MIT License.
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fsaicaca%2Ffuwari.svg?type=large&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fsaicaca%2Ffuwari?ref=badge_large&issueType=license)

172
astro.config.mjs Normal file
View File

@@ -0,0 +1,172 @@
import sitemap from "@astrojs/sitemap";
import svelte from "@astrojs/svelte";
import tailwind from "@astrojs/tailwind";
import { pluginCollapsibleSections } from "@expressive-code/plugin-collapsible-sections";
import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers";
import swup from "@swup/astro";
import expressiveCode from "astro-expressive-code";
import icon from "astro-icon";
import { defineConfig } from "astro/config";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeComponents from "rehype-components"; /* Render the custom directive content */
import rehypeKatex from "rehype-katex";
import rehypeSlug from "rehype-slug";
import remarkDirective from "remark-directive"; /* Handle directives */
import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives";
import remarkMath from "remark-math";
import remarkSectionize from "remark-sectionize";
import { expressiveCodeConfig } from "./src/config.ts";
import { pluginLanguageBadge } from "./src/plugins/expressive-code/language-badge.ts";
import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.mjs";
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs";
import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js";
import { remarkExcerpt } from "./src/plugins/remark-excerpt.js";
import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs";
import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button.js";
// https://astro.build/config
export default defineConfig({
site: "https://fuwari.vercel.app/",
base: "/",
trailingSlash: "always",
integrations: [
tailwind({
nesting: true,
}),
swup({
theme: false,
animationClass: "transition-swup-", // see https://swup.js.org/options/#animationselector
// the default value `transition-` cause transition delay
// when the Tailwind class `transition-all` is used
containers: ["main", "#toc"],
smoothScrolling: true,
cache: true,
preload: true,
accessibility: true,
updateHead: true,
updateBodyClass: false,
globalInstance: true,
}),
icon({
include: {
"preprocess: vitePreprocess(),": ["*"],
"fa6-brands": ["*"],
"fa6-regular": ["*"],
"fa6-solid": ["*"],
},
}),
expressiveCode({
themes: [expressiveCodeConfig.theme, expressiveCodeConfig.theme],
plugins: [
pluginCollapsibleSections(),
pluginLineNumbers(),
pluginLanguageBadge(),
pluginCustomCopyButton()
],
defaultProps: {
wrap: true,
overridesByLang: {
'shellsession': {
showLineNumbers: false,
},
},
},
styleOverrides: {
codeBackground: "var(--codeblock-bg)",
borderRadius: "0.75rem",
borderColor: "none",
codeFontSize: "0.875rem",
codeFontFamily: "'JetBrains Mono Variable', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
codeLineHeight: "1.5rem",
frames: {
editorBackground: "var(--codeblock-bg)",
terminalBackground: "var(--codeblock-bg)",
terminalTitlebarBackground: "var(--codeblock-topbar-bg)",
editorTabBarBackground: "var(--codeblock-topbar-bg)",
editorActiveTabBackground: "none",
editorActiveTabIndicatorBottomColor: "var(--primary)",
editorActiveTabIndicatorTopColor: "none",
editorTabBarBorderBottomColor: "var(--codeblock-topbar-bg)",
terminalTitlebarBorderBottomColor: "none"
},
textMarkers: {
delHue: 0,
insHue: 180,
markHue: 250
}
},
frames: {
showCopyToClipboardButton: false,
}
}),
svelte(),
sitemap(),
],
markdown: {
remarkPlugins: [
remarkMath,
remarkReadingTime,
remarkExcerpt,
remarkGithubAdmonitionsToDirectives,
remarkDirective,
remarkSectionize,
parseDirectiveNode,
],
rehypePlugins: [
rehypeKatex,
rehypeSlug,
[
rehypeComponents,
{
components: {
github: GithubCardComponent,
note: (x, y) => AdmonitionComponent(x, y, "note"),
tip: (x, y) => AdmonitionComponent(x, y, "tip"),
important: (x, y) => AdmonitionComponent(x, y, "important"),
caution: (x, y) => AdmonitionComponent(x, y, "caution"),
warning: (x, y) => AdmonitionComponent(x, y, "warning"),
},
},
],
[
rehypeAutolinkHeadings,
{
behavior: "append",
properties: {
className: ["anchor"],
},
content: {
type: "element",
tagName: "span",
properties: {
className: ["anchor-icon"],
"data-pagefind-ignore": true,
},
children: [
{
type: "text",
value: "#",
},
],
},
},
],
],
},
vite: {
build: {
rollupOptions: {
onwarn(warning, warn) {
// temporarily suppress this warning
if (
warning.message.includes("is dynamically imported by") &&
warning.message.includes("but also statically imported by")
) {
return;
}
warn(warning);
},
},
},
},
});

63
biome.json Normal file
View File

@@ -0,0 +1,63 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!**/src/**/*.css",
"!**/src/public/**/*",
"!**/dist/**/*",
"!**/node_modules/**/*"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"overrides": [
{
"includes": ["**/*.svelte", "**/*.astro", "**/*.vue"],
"linter": {
"rules": {
"style": {
"useConst": "off",
"useImportType": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off"
}
}
}
}
]
}

85
docs/README.es.md Normal file
View File

@@ -0,0 +1,85 @@
# 🍥Fuwari
Un tema estático para blogs construido con [Astro](https://astro.build).
[**🖥️ Demostración en Vivo (Vercel)**](https://fuwari.vercel.app)
![Imagen de Vista Previa](https://raw.githubusercontent.com/saicaca/resource/main/fuwari/home.png)
## ✨ Características
- [x] Construido con [Astro](https://astro.build) y [Tailwind CSS](https://tailwindcss.com)
- [x] Animaciones suaves y transiciones de página
- [x] Modo claro / oscuro
- [x] Colores del tema y banner personalizables
- [x] Diseño responsivo
- [ ] Comentarios
- [x] Buscador
- [x] TOC (Tabla de Contenidos)
## 👀 requiere
- Node.js <= 22
- pnpm <= 9
## 🚀 Cómo Usar 1
Inicializa el proyecto localmente usando [create-fuwari](https://github.com/L4Ph/create-fuwari).
```sh
# npm
npm create fuwari@latest.
# yarn
yarn create fuwari.
# pnpm
pnpm create fuwari@latest
# bun
bun create fuwari@latest
# deno
deno run -A npm:create-fuwari@latest
```
1. Edita el archivo de configuración `src/config.ts` para personalizar tu blog.
2. Ejecuta `pnpm new-post <nombre-de-archivo>` para crear una nueva entrada y edítala en `src/content/posts/`.
3. Despliega tu blog en Vercel, Netlify, GitHub Pages, etc., siguiendo [las guías](https://docs.astro.build/en/guides/deploy/). Necesitas editar la configuración del sitio en `astro.config.mjs` antes del despliegue.
## 🚀 Cómo Usar 2
1. [Genera un nuevo repositorio](https://github.com/saicaca/fuwari/generate) desde esta plantilla o haz un fork de este repositorio.
2. Para editar tu blog localmente, clona tu repositorio, ejecuta `pnpm install` y `pnpm add sharp` para instalar las dependencias.
- Instala [pnpm](https://pnpm.io) `npm install -g pnpm` si aún no lo tienes.
3. Edita el archivo de configuración `src/config.ts` para personalizar tu blog.
4. Ejecuta `pnpm new-post <nombre-de-archivo>` para crear una nueva entrada y edítala en `src/content/posts/`.
5. Despliega tu blog en Vercel, Netlify, GitHub Pages, etc., siguiendo [las guías](https://docs.astro.build/en/guides/deploy/). Necesitas editar la configuración del sitio en `astro.config.mjs` antes del despliegue.
## ⚙️ Cabecera de las Entradas
```yaml
---
title: Mi Primer Post en el Blog
published: 2023-09-09
description: Esta es la primera entrada de mi nuevo blog con Astro.
image: /images/cover.jpg
tags: [Foo, Bar]
category: Front-end
draft: false
---
```
## 🧞 Comandos
Todos los comandos se ejecutan desde la raíz del proyecto, desde una terminal:
| Comando | Acción |
|:------------------------------------|:--------------------------------------------------|
| `pnpm install` y `pnpm add sharp` | Instala las dependencias |
| `pnpm dev` | Inicia el servidor de desarrollo local en `localhost:4321` |
| `pnpm build` | Compila tu web para producción en `./dist/` |
| `pnpm preview` | Previsualiza la web localmente, antes del despliegue |
| `pnpm new-post <nombre-de-archivo>` | Crea una nueva entrada |
| `pnpm astro ...` | Ejecuta comandos CLI como `astro add`, `astro check` |
| `pnpm astro --help` | Obtén ayuda para usar el CLI de Astro |

85
docs/README.ja.md Normal file
View File

@@ -0,0 +1,85 @@
# 🍥Fuwari
[Astro](https://astro.build) で構築された静的ブログテンプレート
[**🖥️ライブデモ (Vercel)**](https://fuwari.vercel.app)
![Preview Image](https://raw.githubusercontent.com/saicaca/resource/main/fuwari/home.png)
## ✨ 特徴
- [x] [Astro](https://astro.build) 及び [Tailwind CSS](https://tailwindcss.com) で構築
- [x] スムーズなアニメーションとページ遷移
- [x] ライト/ダークテーマ対応
- [x] カスタマイズ可能なテーマカラーとバナー
- [x] レスポンシブデザイン
- [ ] コメント機能
- [x] 検索機能
- [x] 目次
## 👀 以下が必要
- Node.js <= 22
- pnpm <= 9
## 🚀 使用方法 1
[create-fuwari](https://github.com/L4Ph/create-fuwari)を使用して、ローカルにプロジェクトを初期化します。
```sh
# npm
npm create fuwari@latest
# yarn
yarn create fuwari
# pnpm
pnpm create fuwari@latest
# bun
bun create fuwari@latest
# deno
deno run -A npm:create-fuwari@latest
```
1. `src/config.ts` ファイルを編集する事でブログを自分好みにカスタマイズ出来ます。
2. `pnpm new-post <filename>` で新しい記事を作成し、`src/content/posts/`.フォルダ内で編集します。
3. 作成したブログをVercel、Netlify、GitHub Pagesなどにデプロイするには[ガイド](https://docs.astro.build/ja/guides/deploy/)に従って下さい。加えて、別途デプロイを行う前に `astro.config.mjs` を編集してサイト構成を変更する必要があります。
## 🚀 使用方法 2
1. [テンプレート](https://github.com/saicaca/fuwari/generate)から新しいリポジトリを作成するかCloneをします。
2. ブログをローカルで編集するには、リポジトリをクローンした後、`pnpm install``pnpm add sharp` を実行して依存関係をインストールします。
- [pnpm](https://pnpm.io) がインストールされていない場合は `npm install -g pnpm` で導入可能です。
3. `src/config.ts` ファイルを編集する事でブログを自分好みにカスタマイズ出来ます。
4. `pnpm new-post <filename>` で新しい記事を作成し、`src/content/posts/`.フォルダ内で編集します。
5. 作成したブログをVercel、Netlify、GitHub Pagesなどにデプロイするには[ガイド](https://docs.astro.build/ja/guides/deploy/)に従って下さい。加えて、別途デプロイを行う前に `astro.config.mjs` を編集してサイト構成を変更する必要があります。
## ⚙️ 記事のフロントマター
```yaml
---
title: My First Blog Post
published: 2023-09-09
description: This is the first post of my new Astro blog.
image: /images/cover.jpg
tags: [Foo, Bar]
category: Front-end
draft: false
---
```
## 🧞 コマンド
すべてのコマンドは、ターミナルでプロジェクトのルートから実行する必要があります:
| Command | Action |
|:------------------------------------|:--------------------------------------------|
| `pnpm install` AND `pnpm add sharp` | 依存関係のインストール |
| `pnpm dev` | `localhost:4321` で開発用ローカルサーバーを起動 |
| `pnpm build` | `./dist/` にビルド内容を出力 |
| `pnpm preview` | デプロイ前の内容をローカルでプレビュー |
| `pnpm new-post <filename>` | 新しい投稿を作成 |
| `pnpm astro ...` | `astro add`, `astro check` の様なコマンドを実行する際に使用 |
| `pnpm astro --help` | Astro CLIのヘルプを表示 |

82
docs/README.ko.md Normal file
View File

@@ -0,0 +1,82 @@
# 🍥Fuwari
[Astro](https://astro.build)로 구축된 정적 블로그 템플릿입니다.
[**🖥️미리보기 (Vercel)**](https://fuwari.vercel.app)
![Preview Image](https://raw.githubusercontent.com/saicaca/resource/main/fuwari/home.png)
## ✨ 특징
- [x] [Astro](https://astro.build) 및 [Tailwind CSS](https://tailwindcss.com)로 구축됨
- [x] 부드러운 애니메이션 및 페이지 전환
- [x] 라이트 모드 / 다크 모드
- [x] 사용자 정의 가능한 테마 색상 및 배너
- [x] 반응형 디자인
- [x] [Pagefind](https://pagefind.app/)를 이용한 검색 기능
- [x] [Markdown 확장 기능](https://github.com/saicaca/fuwari?tab=readme-ov-file#-markdown-extended-syntax)
- [x] 목차
- [x] RSS 피드
## 🚀 시작하기
1. 블로그 저장소를 생성하세요:
- 이 템플릿에서 [새 저장소를 생성](https://github.com/saicaca/fuwari/generate)하거나 이 저장소를 포크하세요.
- 또는 다음 명령어 중 하나를 실행하세요:
```sh
npm create fuwari@latest
yarn create fuwari
pnpm create fuwari@latest
bun create fuwari@latest
deno run -A npm:create-fuwari@latest
```
2. 로컬에서 블로그를 수정하려면, 저장소를 복제하고 `pnpm install`을 실행하여 종속성을 설치하세요.
- [pnpm](https://pnpm.io)이 설치되어 있지 않다면 `npm install -g pnpm`을 실행하여 설치하세요.
3. `src/config.ts`설정 파일을 수정하여 블로그를 커스터마이징하세요.
4. `pnpm new-post <filename>`을 실행하여 새 게시물을 만들고 `src/content/posts/`에서 수정하세요.
5. [가이드](https://docs.astro.build/en/guides/deploy/)에 따라 블로그를 Vercel, Netlify, Github Pages 등에 배포하세요. 배포하기 전에 `astro.config.mjs`에서 사이트 구성을 수정해야 합니다.
## ⚙️ 게시물의 머리말 설정
```yaml
---
title: 내 첫 블로그 게시물
published: 2023-09-09
description: 내 새로운 Astro 블로그의 첫 번째 게시물입니다!
image: ./cover.jpg
tags: [Foo, Bar]
category: Front-end
draft: false
lang: jp # 게시물의 언어가 `config.ts`의 사이트 언어와 다른 경우에만 설정합니다.
---
```
## 🧩 마크다운 확장 구문
Astro의 기본 [GitHub Flavored Markdown](https://github.github.com/gfm/) 지원 외에도 몇 가지 추가적인 마크다운 기능이 포함되어 있습니다.
- Admonitions ([미리보기 및 사용법](https://fuwari.vercel.app/posts/markdown-extended/#admonitions))
- GitHub 저장소 카드 ([미리보기 및 사용법](https://fuwari.vercel.app/posts/markdown-extended/#github-repository-cards))
- Expressive Code를 사용한 향상된 코드 블록 ([미리보기](https://fuwari.vercel.app/posts/expressive-code/) / [문서](https://expressive-code.com/))
## ⚡ 명령어
모든 명령어는 프로젝트 최상단, 터미널에서 실행됩니다:
| Command | Action |
|:------------------------------------|:-------------------------------------------------|
| `pnpm install` | 종속성을 설치합니다. |
| `pnpm dev` | `localhost:4321`에서 로컬 개발 서버를 시작합니다. |
| `pnpm build` | `./dist/`에 프로덕션 사이트를 구축합니다. |
| `pnpm check` | 코드에서 오류를 확인합니다. |
| `pnpm format` | Biome을 사용하여 코드를 포멧합니다. |
| `pnpm preview` | 배포하기 전에 로컬에서 빌드 미리보기 |
| `pnpm new-post <filename>` | 새 게시물 작성 |
| `pnpm astro ...` | `astro add`, `astro check`와 같은 CLI 명령어 실행 |
| `pnpm astro --help` | Astro CLI를 사용하여 도움 받기 |
## ✏️ 기여
이 프로젝트에 기여하는 방법에 대한 자세한 내용은 [기여 가이드](https://github.com/saicaca/fuwari/blob/main/CONTRIBUTING.md)를 확인하세요.
## 📄 라이선스
이 프로젝트는 MIT 라이선스에 따라 라이선스가 부여됩니다.
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fsaicaca%2Ffuwari.svg?type=large&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fsaicaca%2Ffuwari?ref=badge_large&issueType=license)

84
docs/README.th.md Normal file
View File

@@ -0,0 +1,84 @@
# 🍥Fuwari
แม่แบบสำหรับเว็บบล็อกแบบ static สร้างด้วย [Astro](https://astro.build)
[**🖥️ ตัวอย่างการใช้งานจริง (Vercel)**](https://fuwari.vercel.app)
![ภาพตัวอย่าง](https://raw.githubusercontent.com/saicaca/resource/main/fuwari/home.png)
## ✨ คุณสมบัติ
- [x] สร้างด้วย [Astro](https://astro.build) และ [Tailwind CSS](https://tailwindcss.com)
- [x] มีอนิเมชั่นและการเปลี่ยนหน้าอย่างลื่นไหล
- [x] โหมดสว่าง / โหมดมืด
- [x] ปรับแต่งสีธีมและแบนเนอร์ได้
- [x] Responsive design (หน้าตาเว็บปรับเปลี่ยนตามขนาดจอ)
- [x] ฟังก์ชันการค้นหา ขับเคลื่อนด้วย [Pagefind](https://pagefind.app/)
- [x] [คุณสมบัติเพิ่มเติมสำหรับมาร์กดาวน์](https://github.com/saicaca/fuwari/blob/main/docs/README.th.md#-markdown-extended-syntax)
- [x] สารบัญ
- [x] RSS feed
## 🚀 เริ่มต้นใช้งาน
1. สร้าง repository ใหม่สำหรับบล็อกของคุณ:
- [Generate repository ใหม่](https://github.com/saicaca/fuwari/generate) ขึ้นมาจากแม่แบบนี้ หรือจะ fork repository นี้ก็ได้
- หรือจะสร้างโดยการเลือกรันคำสั่งต่อไปนี้ คำสั่งใดคำสั่งหนึ่ง:
```sh
npm create fuwari@latest
yarn create fuwari
pnpm create fuwari@latest
bun create fuwari@latest
deno run -A npm:create-fuwari@latest
```
2. เริ่มแก้ไขบล็อกของคุณแบบ local โดยการ clone repository ของคุณ (จากข้อ 1) ไว้ในเครื่องของคุณ แล้วรันคำสั่ง `pnpm install` เพื่อติดตั้ง dependencies ที่จำเป็น
- ติดตั้ง [pnpm](https://pnpm.io) ด้วยคำสั่ง `npm install -g pnpm` ก่อน ถ้ายังไม่เคยติดตั้ง
3. แก้ไขไฟล์การตั้งค่า `src/config.ts` เพื่อปรับแต่งบล็อกของคุณ
4. รันคำสั่ง `pnpm new-post <filename>` เพื่อสร้างโพสต์ใหม่ใน `src/content/posts/` และแก้ไขไฟล์โพสต์นั้นๆ ให้สมบูรณ์
5. Deploy เว็บบล็อกของคุณไปยัง Vercel, Netlify, GitHub Pages หรือบริการอื่นๆ โดยอ้างอิงวิธีการจาก[คู่มือนี้](https://docs.astro.build/en/guides/deploy/) อย่าลืมแก้ไขการตั้งค่าเว็บไซต์ในไฟล์ `astro.config.mjs` ก่อนที่คุณจะ deploy เว็บ
## 📝 Frontmatter (ส่วนหัวไฟล์) ของโพสต์
```yaml
---
title: โพสต์แรกของฉัน
published: 2023-09-09
description: นี่คือโพสต์แรกของเว็บบล็อก Astro อันใหม่ของฉัน
image: ./cover.jpg
tags: [Foo, Bar]
category: Front-end
draft: false
lang: jp # เขียนค่านี้เมื่อภาษาของโพสต์นั้นๆ แตกต่างจากภาษาของเว็บไซต์ที่ตั้งค่าไว้ใน `config.ts` เท่านั้น
---
```
## 🧩 Markdown Extended Syntax
เดิมที Astro มีการสนับสนุน[ภาษามาร์กดาวน์แบบของ GitHub](https://github.github.com/gfm/) ไว้อยู่แล้ว แต่ Fuwari ได้เพิ่มเติมคุณสมบัติพิเศษอื่นๆ เข้าไปอีก:
- Admonitions หรือ กล่องข้อมูลพิเศษ ([ดูตัวอย่างและการใช้งาน](https://fuwari.vercel.app/posts/markdown-extended/#admonitions))
- การ์ด GitHub Repository ([ดูตัวอย่างและการใช้งาน](https://fuwari.vercel.app/posts/markdown-extended/#github-repository-cards))
- บล็อกโค้ดขั้นสูง ด้วย Expressive Code ([ดูตัวอย่าง](https://fuwari.vercel.app/posts/expressive-code/) / [เอกสารประกอบ](https://expressive-code.com/))
## ⚡ คำสั่ง
คำสั่งที่รันได้ใน terminal จาก root ของโปรเจกต์:
| คำสั่ง | การทำงาน |
|:---------------------------|:-------------------------------------------------------|
| `pnpm install` | ติดตั้ง dependencies |
| `pnpm dev` | เปิดเซิร์ฟเวอร์สำหรับการพัฒนาแบบ local ที่ `localhost:4321` |
| `pnpm build` | Build เว็บไซต์สำหรับใช้งานจริงไปยังโฟลเดอร์ `./dist/` |
| `pnpm preview` | ดูตัวอย่าง build ของคุณแบบ local ก่อนที่จะ deploy จริง |
| `pnpm check` | ดำเนินการตรวจสอบหาข้อผิดพลาดในโค้ดของคุณ |
| `pnpm format` | จัดรูปแบบโค้ดของคุณด้วย Biome |
| `pnpm new-post <filename>` | สร้างโพสต์ใหม่ |
| `pnpm astro ...` | รันคำสั่ง CLI เช่น `astro add`, `astro check` |
| `pnpm astro --help` | แสดงวิธีใช้งาน Astro CLI |
## ✏️ การมีส่วนร่วม
กรุณาอ่าน [แนวทางการมีส่วนร่วม](https://github.com/saicaca/fuwari/blob/main/CONTRIBUTING.md) สำหรับรายละเอียดวิธีการมีส่วนร่วมในโปรเจกต์นี้
## 📄 สัญญาอนุญาต
โปรเจกต์นี้เผยแพร่ภายใต้สัญญาอนุญาตแบบ MIT License

86
docs/README.zh-CN.md Normal file
View File

@@ -0,0 +1,86 @@
# 🍥Fuwari
基于 [Astro](https://astro.build) 开发的静态博客模板。
[**🖥在线预览Vercel**](https://fuwari.vercel.app)
![Preview Image](https://raw.githubusercontent.com/saicaca/resource/main/fuwari/home.png)
## ✨ 功能特性
- [x] 基于 Astro 和 Tailwind CSS 开发
- [x] 流畅的动画和页面过渡
- [x] 亮色 / 暗色模式
- [x] 自定义主题色和横幅图片
- [x] 响应式设计
- [ ] 评论
- [x] 搜索
- [x] 文内目录
## 👀 要求
- Node.js <= 22
- pnpm <= 9
## 🚀 使用方法 1
使用 [create-fuwari](https://github.com/L4Ph/create-fuwari) 在本地初始化项目。
```sh
# npm
npm create fuwari@latest
# yarn
yarn create fuwari
# pnpm
pnpm create fuwari@latest
# bun
bun create fuwari@latest
# deno
deno run -A npm:create-fuwari@latest
```
1. 通过配置文件 `src/config.ts` 自定义博客
2. 执行 `pnpm new-post <filename>` 创建新文章,并在 `src/content/posts/` 目录中编辑
3. 参考[官方指南](https://docs.astro.build/zh-cn/guides/deploy/)将博客部署至 Vercel, Netlify, GitHub Pages 等;部署前需编辑 `astro.config.mjs` 中的站点设置。
## 🚀 使用方法 2
1. 使用此模板[生成新仓库](https://github.com/saicaca/fuwari/generate)或 Fork 此仓库
2. 进行本地开发Clone 新的仓库,执行 `pnpm install``pnpm add sharp` 以安装依赖
- 若未安装 [pnpm](https://pnpm.io),执行 `npm install -g pnpm`
3. 通过配置文件 `src/config.ts` 自定义博客
4. 执行 `pnpm new-post <filename>` 创建新文章,并在 `src/content/posts/` 目录中编辑
5. 参考[官方指南](https://docs.astro.build/zh-cn/guides/deploy/)将博客部署至 Vercel, Netlify, GitHub Pages 等;部署前需编辑 `astro.config.mjs` 中的站点设置。
## ⚙️ 文章 Frontmatter
```yaml
---
title: My First Blog Post
published: 2023-09-09
description: This is the first post of my new Astro blog.
image: ./cover.jpg
tags: [Foo, Bar]
category: Front-end
draft: false
lang: jp # 仅当文章语言与 `config.ts` 中的网站语言不同时需要设置
---
```
## 🧞 指令
下列指令均需要在项目根目录执行:
| Command | Action |
|:----------------------------------|:----------------------------------|
| `pnpm install``pnpm add sharp` | 安装依赖 |
| `pnpm dev` | 在 `localhost:4321` 启动本地开发服务器 |
| `pnpm build` | 构建网站至 `./dist/` |
| `pnpm preview` | 本地预览已构建的网站 |
| `pnpm new-post <filename>` | 创建新文章 |
| `pnpm astro ...` | 执行 `astro add`, `astro check` 等指令 |
| `pnpm astro --help` | 显示 Astro CLI 帮助 |

67
frontmatter.json Normal file
View File

@@ -0,0 +1,67 @@
{
"$schema": "https://frontmatter.codes/frontmatter.schema.json",
"frontMatter.framework.id": "astro",
"frontMatter.preview.host": "http://localhost:4321",
"frontMatter.content.publicFolder": "public",
"frontMatter.content.pageFolders": [
{
"title": "posts",
"path": "[[workspace]]/src/content/posts"
}
],
"frontMatter.taxonomy.contentTypes": [
{
"name": "default",
"pageBundle": true,
"previewPath": "'blog'",
"filePrefix": null,
"clearEmpty": true,
"fields": [
{
"title": "title",
"name": "title",
"type": "string",
"single": true
},
{
"title": "description",
"name": "description",
"type": "string"
},
{
"title": "published",
"name": "published",
"type": "datetime",
"default": "{{now}}",
"isPublishDate": true
},
{
"title": "preview",
"name": "image",
"type": "image",
"isPreviewImage": true
},
{
"title": "tags",
"name": "tags",
"type": "list"
},
{
"title": "category",
"name": "category",
"type": "string"
},
{
"title": "draft",
"name": "draft",
"type": "boolean"
},
{
"title": "language",
"name": "language",
"type": "string"
}
]
}
]
}

76
package.json Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "fuwari",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"check": "astro check",
"build": "astro build && pagefind --site dist",
"preview": "astro preview",
"astro": "astro",
"type-check": "tsc --noEmit --isolatedDeclarations",
"new-post": "node scripts/new-post.js",
"format": "biome format --write ./src",
"lint": "biome check --write ./src",
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.4.1",
"@astrojs/svelte": "7.1.0",
"@astrojs/tailwind": "^6.0.2",
"@expressive-code/core": "^0.41.2",
"@expressive-code/plugin-collapsible-sections": "^0.41.2",
"@expressive-code/plugin-line-numbers": "^0.41.2",
"@fontsource-variable/jetbrains-mono": "^5.2.6",
"@fontsource/roboto": "^5.2.6",
"@iconify-json/fa6-brands": "^1.2.5",
"@iconify-json/fa6-regular": "^1.2.3",
"@iconify-json/fa6-solid": "^1.2.3",
"@iconify-json/material-symbols": "^1.2.28",
"@iconify/svelte": "^4.2.0",
"@swup/astro": "^1.6.0",
"@tailwindcss/typography": "^0.5.16",
"astro": "5.10.2",
"astro-expressive-code": "^0.41.2",
"astro-icon": "^1.1.5",
"hastscript": "^9.0.1",
"katex": "^0.16.22",
"markdown-it": "^14.1.0",
"mdast-util-to-string": "^4.0.0",
"overlayscrollbars": "^2.11.4",
"pagefind": "^1.3.0",
"photoswipe": "^5.4.4",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-components": "^0.3.0",
"rehype-katex": "^7.0.1",
"rehype-slug": "^6.0.0",
"remark-directive": "^3.0.1",
"remark-directive-rehype": "^0.4.2",
"remark-github-admonitions-to-directives": "^1.0.5",
"remark-math": "^6.0.0",
"remark-sectionize": "^2.1.0",
"sanitize-html": "^2.17.0",
"sharp": "^0.34.2",
"stylus": "^0.64.0",
"svelte": "^5.34.8",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@astrojs/ts-plugin": "^1.10.4",
"@biomejs/biome": "2.0.6",
"@rollup/plugin-yaml": "^4.1.2",
"@types/hast": "^3.0.4",
"@types/markdown-it": "^14.1.2",
"@types/mdast": "^4.0.4",
"@types/sanitize-html": "^2.16.0",
"postcss-import": "^16.1.1",
"postcss-nesting": "^13.0.2"
},
"packageManager": "pnpm@9.14.4"
}

6
pagefind.yml Normal file
View File

@@ -0,0 +1,6 @@
exclude_selectors:
- "span.katex"
- "span.katex-display"
- "[data-pagefind-ignore]"
- ".search-panel"
- "#search-panel"

11295
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

11
postcss.config.mjs Normal file
View File

@@ -0,0 +1,11 @@
import postcssImport from 'postcss-import';
import postcssNesting from 'tailwindcss/nesting/index.js';
import tailwindcss from 'tailwindcss';
export default {
plugins: {
'postcss-import': postcssImport, // to combine multiple css files
'tailwindcss/nesting': postcssNesting,
tailwindcss: tailwindcss,
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

59
scripts/new-post.js Normal file
View File

@@ -0,0 +1,59 @@
/* This is a script to create a new post markdown file with front-matter */
import fs from "fs"
import path from "path"
function getDate() {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, "0")
const day = String(today.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
const args = process.argv.slice(2)
if (args.length === 0) {
console.error(`Error: No filename argument provided
Usage: npm run new-post -- <filename>`)
process.exit(1) // Terminate the script and return error code 1
}
let fileName = args[0]
// Add .md extension if not present
const fileExtensionRegex = /\.(md|mdx)$/i
if (!fileExtensionRegex.test(fileName)) {
fileName += ".md"
}
const targetDir = "./src/content/posts/"
const fullPath = path.join(targetDir, fileName)
if (fs.existsSync(fullPath)) {
console.error(`Error: File ${fullPath} already exists `)
process.exit(1)
}
// recursive mode creates multi-level directories
const dirPath = path.dirname(fullPath)
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
const content = `---
title: ${args[0]}
published: ${getDate()}
description: ''
image: ''
tags: []
category: ''
draft: false
lang: ''
---
`
fs.writeFileSync(path.join(targetDir, fileName), content)
console.log(`Post ${fullPath} created`)

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 KiB

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import { onMount } from "svelte";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import { getPostUrlBySlug } from "../utils/url-utils";
export let tags: string[];
export let categories: string[];
export let sortedPosts: Post[] = [];
const params = new URLSearchParams(window.location.search);
tags = params.has("tag") ? params.getAll("tag") : [];
categories = params.has("category") ? params.getAll("category") : [];
const uncategorized = params.get("uncategorized");
interface Post {
slug: string;
data: {
title: string;
tags: string[];
category?: string;
published: Date;
};
}
interface Group {
year: number;
posts: Post[];
}
let groups: Group[] = [];
function formatDate(date: Date) {
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${month}-${day}`;
}
function formatTag(tagList: string[]) {
return tagList.map((t) => `#${t}`).join(" ");
}
onMount(async () => {
let filteredPosts: Post[] = sortedPosts;
if (tags.length > 0) {
filteredPosts = filteredPosts.filter(
(post) =>
Array.isArray(post.data.tags) &&
post.data.tags.some((tag) => tags.includes(tag)),
);
}
if (categories.length > 0) {
filteredPosts = filteredPosts.filter(
(post) => post.data.category && categories.includes(post.data.category),
);
}
if (uncategorized) {
filteredPosts = filteredPosts.filter((post) => !post.data.category);
}
const grouped = filteredPosts.reduce(
(acc, post) => {
const year = post.data.published.getFullYear();
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(post);
return acc;
},
{} as Record<number, Post[]>,
);
const groupedPostsArray = Object.keys(grouped).map((yearStr) => ({
year: Number.parseInt(yearStr),
posts: grouped[Number.parseInt(yearStr)],
}));
groupedPostsArray.sort((a, b) => b.year - a.year);
groups = groupedPostsArray;
});
</script>
<div class="card-base px-8 py-6">
{#each groups as group}
<div>
<div class="flex flex-row w-full items-center h-[3.75rem]">
<div class="w-[15%] md:w-[10%] transition text-2xl font-bold text-right text-75">
{group.year}
</div>
<div class="w-[15%] md:w-[10%]">
<div
class="h-3 w-3 bg-none rounded-full outline outline-[var(--primary)] mx-auto
-outline-offset-[2px] z-50 outline-3"
></div>
</div>
<div class="w-[70%] md:w-[80%] transition text-left text-50">
{group.posts.length} {i18n(group.posts.length === 1 ? I18nKey.postCount : I18nKey.postsCount)}
</div>
</div>
{#each group.posts as post}
<a
href={getPostUrlBySlug(post.slug)}
aria-label={post.data.title}
class="group btn-plain !block h-10 w-full rounded-lg hover:text-[initial]"
>
<div class="flex flex-row justify-start items-center h-full">
<!-- date -->
<div class="w-[15%] md:w-[10%] transition text-sm text-right text-50">
{formatDate(post.data.published)}
</div>
<!-- dot and line -->
<div class="w-[15%] md:w-[10%] relative dash-line h-full flex items-center">
<div
class="transition-all mx-auto w-1 h-1 rounded group-hover:h-5
bg-[oklch(0.5_0.05_var(--hue))] group-hover:bg-[var(--primary)]
outline outline-4 z-50
outline-[var(--card-bg)]
group-hover:outline-[var(--btn-plain-bg-hover)]
group-active:outline-[var(--btn-plain-bg-active)]"
></div>
</div>
<!-- post title -->
<div
class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{post.data.title}
</div>
<!-- tag list -->
<div
class="hidden md:block md:w-[15%] text-left text-sm transition
whitespace-nowrap overflow-ellipsis overflow-hidden text-30"
>
{formatTag(post.data.tags)}
</div>
</div>
</a>
{/each}
</div>
{/each}
</div>

View File

@@ -0,0 +1,7 @@
---
import { siteConfig } from "../config";
---
<div id="config-carrier" data-hue={siteConfig.themeColor.hue}>
</div>

View File

@@ -0,0 +1,21 @@
---
import { profileConfig } from "../config";
import { url } from "../utils/url-utils";
const currentYear = new Date().getFullYear();
---
<!--<div class="border-t border-[var(&#45;&#45;primary)] mx-16 border-dashed py-8 max-w-[var(&#45;&#45;page-width)] flex flex-col items-center justify-center px-6">-->
<div class="transition border-t border-black/10 dark:border-white/15 my-10 border-dashed mx-32"></div>
<!--<div class="transition bg-[oklch(92%_0.01_var(&#45;&#45;hue))] dark:bg-black rounded-2xl py-8 mt-4 mb-8 flex flex-col items-center justify-center px-6">-->
<div class="transition border-dashed border-[oklch(85%_0.01_var(--hue))] dark:border-white/15 rounded-2xl mb-12 flex flex-col items-center justify-center px-6">
<div class="transition text-50 text-sm text-center">
&copy; <span id="copyright-year">{currentYear}</span> {profileConfig.name}. All Rights Reserved. /
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href={url('rss.xml')}>RSS</a> /
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href={url('sitemap-index.xml')}>Sitemap</a><br>
Powered by
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://astro.build">Astro</a> &
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://github.com/saicaca/fuwari">Fuwari</a>
</div>
</div>

View File

@@ -0,0 +1,3 @@
---
---

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import { AUTO_MODE, DARK_MODE, LIGHT_MODE } from "@constants/constants.ts";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import {
applyThemeToDocument,
getStoredTheme,
setTheme,
} from "@utils/setting-utils.ts";
import { onMount } from "svelte";
import type { LIGHT_DARK_MODE } from "@/types/config.ts";
const seq: LIGHT_DARK_MODE[] = [LIGHT_MODE, DARK_MODE, AUTO_MODE];
let mode: LIGHT_DARK_MODE = $state(AUTO_MODE);
onMount(() => {
mode = getStoredTheme();
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
const changeThemeWhenSchemeChanged: Parameters<
typeof darkModePreference.addEventListener<"change">
>[1] = (_e) => {
applyThemeToDocument(mode);
};
darkModePreference.addEventListener("change", changeThemeWhenSchemeChanged);
return () => {
darkModePreference.removeEventListener(
"change",
changeThemeWhenSchemeChanged,
);
};
});
function switchScheme(newMode: LIGHT_DARK_MODE) {
mode = newMode;
setTheme(newMode);
}
function toggleScheme() {
let i = 0;
for (; i < seq.length; i++) {
if (seq[i] === mode) {
break;
}
}
switchScheme(seq[(i + 1) % seq.length]);
}
function showPanel() {
const panel = document.querySelector("#light-dark-panel");
panel.classList.remove("float-panel-closed");
}
function hidePanel() {
const panel = document.querySelector("#light-dark-panel");
panel.classList.add("float-panel-closed");
}
</script>
<!-- z-50 make the panel higher than other float panels -->
<div class="relative z-50" role="menu" tabindex="-1" onmouseleave={hidePanel}>
<button aria-label="Light/Dark Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="scheme-switch" onclick={toggleScheme} onmouseenter={showPanel}>
<div class="absolute" class:opacity-0={mode !== LIGHT_MODE}>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== DARK_MODE}>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== AUTO_MODE}>
<Icon icon="material-symbols:radio-button-partial-outline" class="text-[1.25rem]"></Icon>
</div>
</button>
<div id="light-dark-panel" class="hidden lg:block absolute transition float-panel-closed top-11 -right-2 pt-5" >
<div class="card-base float-panel p-2">
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5"
class:current-theme-btn={mode === LIGHT_MODE}
onclick={() => switchScheme(LIGHT_MODE)}
>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.lightMode)}
</button>
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5"
class:current-theme-btn={mode === DARK_MODE}
onclick={() => switchScheme(DARK_MODE)}
>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.darkMode)}
</button>
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95"
class:current-theme-btn={mode === AUTO_MODE}
onclick={() => switchScheme(AUTO_MODE)}
>
<Icon icon="material-symbols:radio-button-partial-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.systemMode)}
</button>
</div>
</div>
</div>

141
src/components/Navbar.astro Normal file
View File

@@ -0,0 +1,141 @@
---
import { Icon } from "astro-icon/components";
import { navBarConfig, siteConfig } from "../config";
import { LinkPresets } from "../constants/link-presets";
import { LinkPreset, type NavBarLink } from "../types/config";
import { url } from "../utils/url-utils";
import LightDarkSwitch from "./LightDarkSwitch.svelte";
import Search from "./Search.svelte";
import DisplaySettings from "./widget/DisplaySettings.svelte";
import NavMenuPanel from "./widget/NavMenuPanel.astro";
const className = Astro.props.class;
let links: NavBarLink[] = navBarConfig.links.map(
(item: NavBarLink | LinkPreset): NavBarLink => {
if (typeof item === "number") {
return LinkPresets[item];
}
return item;
},
);
---
<div id="navbar" class="z-50 onload-animation">
<div class="absolute h-8 left-0 right-0 -top-8 bg-[var(--card-bg)] transition"></div> <!-- used for onload animation -->
<div class:list={[
className,
"card-base !overflow-visible max-w-[var(--page-width)] h-[4.5rem] !rounded-t-none mx-auto flex items-center justify-between px-4"]}>
<a href={url('/')} class="btn-plain scale-animation rounded-lg h-[3.25rem] px-5 font-bold active:scale-95">
<div class="flex flex-row text-[var(--primary)] items-center text-md">
<Icon name="material-symbols:home-outline-rounded" class="text-[1.75rem] mb-1 mr-2" />
{siteConfig.title}
</div>
</a>
<div class="hidden md:flex">
{links.map((l) => {
return <a aria-label={l.name} href={l.external ? l.url : url(l.url)} target={l.external ? "_blank" : null}
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95"
>
<div class="flex items-center">
{l.name}
{l.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]"></Icon>}
</div>
</a>;
})}
</div>
<div class="flex">
<!--<SearchPanel client:load>-->
<Search client:only="svelte"></Search>
{!siteConfig.themeColor.fixed && (
<button aria-label="Display Settings" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="display-settings-switch">
<Icon name="material-symbols:palette-outline" class="text-[1.25rem]"></Icon>
</button>
)}
<LightDarkSwitch client:only="svelte"></LightDarkSwitch>
<button aria-label="Menu" name="Nav Menu" class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90 md:!hidden" id="nav-menu-switch">
<Icon name="material-symbols:menu-rounded" class="text-[1.25rem]"></Icon>
</button>
</div>
<NavMenuPanel links={links}></NavMenuPanel>
<DisplaySettings client:only="svelte"></DisplaySettings>
</div>
</div>
<script>
function switchTheme() {
if (localStorage.theme === 'dark') {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
}
}
function loadButtonScript() {
let switchBtn = document.getElementById("scheme-switch");
if (switchBtn) {
switchBtn.onclick = function () {
switchTheme()
};
}
let settingBtn = document.getElementById("display-settings-switch");
if (settingBtn) {
settingBtn.onclick = function () {
let settingPanel = document.getElementById("display-setting");
if (settingPanel) {
settingPanel.classList.toggle("float-panel-closed");
}
};
}
let menuBtn = document.getElementById("nav-menu-switch");
if (menuBtn) {
menuBtn.onclick = function () {
let menuPanel = document.getElementById("nav-menu-panel");
if (menuPanel) {
menuPanel.classList.toggle("float-panel-closed");
}
};
}
}
loadButtonScript();
</script>
{import.meta.env.PROD && <script is:inline define:vars={{scriptUrl: url('/pagefind/pagefind.js')}}>
async function loadPagefind() {
try {
const response = await fetch(scriptUrl, { method: 'HEAD' });
if (!response.ok) {
throw new Error(`Pagefind script not found: ${response.status}`);
}
const pagefind = await import(scriptUrl);
await pagefind.options({
excerptLength: 20
});
window.pagefind = pagefind;
document.dispatchEvent(new CustomEvent('pagefindready'));
console.log('Pagefind loaded and initialized successfully, event dispatched.');
} catch (error) {
console.error('Failed to load Pagefind:', error);
window.pagefind = {
search: () => Promise.resolve({ results: [] }),
options: () => Promise.resolve(),
};
document.dispatchEvent(new CustomEvent('pagefindloaderror'));
console.log('Pagefind load error, event dispatched.');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadPagefind);
} else {
loadPagefind();
}
</script>}

View File

@@ -0,0 +1,110 @@
---
import type { CollectionEntry } from "astro:content";
import path from "node:path";
import { Icon } from "astro-icon/components";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import { getDir } from "../utils/url-utils";
import ImageWrapper from "./misc/ImageWrapper.astro";
import PostMetadata from "./PostMeta.astro";
interface Props {
class?: string;
entry: CollectionEntry<"posts">;
title: string;
url: string;
published: Date;
updated?: Date;
tags: string[];
category: string | null;
image: string;
description: string;
draft: boolean;
style: string;
}
const {
entry,
title,
url,
published,
updated,
tags,
category,
image,
description,
style,
} = Astro.props;
const className = Astro.props.class;
const hasCover = image !== undefined && image !== null && image !== "";
const coverWidth = "28%";
const { remarkPluginFrontmatter } = await entry.render();
---
<div class:list={["card-base flex flex-col-reverse md:flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative", className]} style={style}>
<div class:list={["pl-6 md:pl-9 pr-6 md:pr-2 pt-6 md:pt-7 pb-6 relative", {"w-full md:w-[calc(100%_-_52px_-_12px)]": !hasCover, "w-full md:w-[calc(100%_-_var(--coverWidth)_-_12px)]": hasCover}]}>
<a href={url}
class="transition group w-full block font-bold mb-3 text-3xl text-90
hover:text-[var(--primary)] dark:hover:text-[var(--primary)]
active:text-[var(--title-active)] dark:active:text-[var(--title-active)]
before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-[35px] before:left-[18px] before:hidden md:before:block
">
{title}
<Icon class="inline text-[2rem] text-[var(--primary)] md:hidden translate-y-0.5 absolute" name="material-symbols:chevron-right-rounded" ></Icon>
<Icon class="text-[var(--primary)] text-[2rem] transition hidden md:inline absolute translate-y-0.5 opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0" name="material-symbols:chevron-right-rounded"></Icon>
</a>
<!-- metadata -->
<PostMetadata published={published} updated={updated} tags={tags} category={category} hideTagsForMobile={true} hideUpdateDate={true} class="mb-4"></PostMetadata>
<!-- description -->
<div class:list={["transition text-75 mb-3.5 pr-4", {"line-clamp-2 md:line-clamp-1": !description}]}>
{ description || remarkPluginFrontmatter.excerpt }
</div>
<!-- word count and read time -->
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
<div>
{remarkPluginFrontmatter.words} {" " + i18n(remarkPluginFrontmatter.words === 1 ? I18nKey.wordCount : I18nKey.wordsCount)}
</div>
<div>|</div>
<div>
{remarkPluginFrontmatter.minutes} {" " + i18n(remarkPluginFrontmatter.minutes === 1 ? I18nKey.minuteCount : I18nKey.minutesCount)}
</div>
</div>
</div>
{hasCover && <a href={url} aria-label={title}
class:list={["group",
"max-h-[20vh] md:max-h-none mx-4 mt-4 -mb-2 md:mb-0 md:mx-0 md:mt-0",
"md:w-[var(--coverWidth)] relative md:absolute md:top-3 md:bottom-3 md:right-3 rounded-xl overflow-hidden active:scale-95"
]} >
<div class="absolute pointer-events-none z-10 w-full h-full group-hover:bg-black/30 group-active:bg-black/50 transition"></div>
<div class="absolute pointer-events-none z-20 w-full h-full flex items-center justify-center ">
<Icon name="material-symbols:chevron-right-rounded"
class="transition opacity-0 group-hover:opacity-100 scale-50 group-hover:scale-100 text-white text-5xl">
</Icon>
</div>
<ImageWrapper src={image} basePath={path.join("content/posts/", getDir(entry.id))} alt="Cover Image of the Post"
class="w-full h-full">
</ImageWrapper>
</a>}
{!hasCover &&
<a href={url} aria-label={title} class="!hidden md:!flex btn-regular w-[3.25rem]
absolute right-3 top-3 bottom-3 rounded-xl bg-[var(--enter-btn-bg)]
hover:bg-[var(--enter-btn-bg-hover)] active:bg-[var(--enter-btn-bg-active)] active:scale-95
">
<Icon name="material-symbols:chevron-right-rounded"
class="transition text-[var(--primary)] text-4xl mx-auto">
</Icon>
</a>
}
</div>
<div class="transition border-t-[1px] border-dashed mx-6 border-black/10 dark:border-white/[0.15] last:border-t-0 md:hidden"></div>
<style define:vars={{coverWidth}}>
</style>

View File

@@ -0,0 +1,82 @@
---
import { Icon } from "astro-icon/components";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import { formatDateToYYYYMMDD } from "../utils/date-utils";
import { getCategoryUrl, getTagUrl } from "../utils/url-utils";
interface Props {
class: string;
published: Date;
updated?: Date;
tags: string[];
category: string | null;
hideTagsForMobile?: boolean;
hideUpdateDate?: boolean;
}
const {
published,
updated,
tags,
category,
hideTagsForMobile = false,
hideUpdateDate = false,
} = Astro.props;
const className = Astro.props.class;
---
<div class:list={["flex flex-wrap text-neutral-500 dark:text-neutral-400 items-center gap-4 gap-x-4 gap-y-2", className]}>
<!-- publish date -->
<div class="flex items-center">
<div class="meta-icon"
>
<Icon name="material-symbols:calendar-today-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(published)}</span>
</div>
<!-- update date -->
{!hideUpdateDate && updated && updated.getTime() !== published.getTime() && (
<div class="flex items-center">
<div class="meta-icon"
>
<Icon name="material-symbols:edit-calendar-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(updated)}</span>
</div>
)}
<!-- categories -->
<div class="flex items-center">
<div class="meta-icon"
>
<Icon name="material-symbols:book-2-outline-rounded" class="text-xl"></Icon>
</div>
<div class="flex flex-row flex-nowrap items-center">
<a href={getCategoryUrl(category)} aria-label={`View all posts in the ${category} category`}
class="link-lg transition text-50 text-sm font-medium
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
{category || i18n(I18nKey.uncategorized)}
</a>
</div>
</div>
<!-- tags -->
<div class:list={["items-center", {"flex": !hideTagsForMobile, "hidden md:flex": hideTagsForMobile}]}>
<div class="meta-icon"
>
<Icon name="material-symbols:tag-rounded" class="text-xl"></Icon>
</div>
<div class="flex flex-row flex-nowrap items-center">
{(tags && tags.length > 0) && tags.map((tag, i) => (
<div class:list={[{"hidden": i == 0}, "mx-1.5 text-[var(--meta-divider)] text-sm"]}>/</div>
<a href={getTagUrl(tag)} aria-label={`View all posts with the ${tag.trim()} tag`}
class="link-lg transition text-50 text-sm font-medium
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
{tag.trim()}
</a>
))}
{!(tags && tags.length > 0) && <div class="transition text-50 text-sm font-medium">{i18n(I18nKey.noTags)}</div>}
</div>
</div>
</div>

View File

@@ -0,0 +1,28 @@
---
import type { CollectionEntry } from "astro:content";
import { getPostUrlBySlug } from "@utils/url-utils";
import PostCard from "./PostCard.astro";
const { page } = Astro.props;
let delay = 0;
const interval = 50;
---
<div class="transition flex flex-col rounded-[var(--radius-large)] bg-[var(--card-bg)] py-1 md:py-0 md:bg-transparent md:gap-4 mb-4">
{page.data.map((entry: CollectionEntry<"posts">) => (
<PostCard
entry={entry}
title={entry.data.title}
tags={entry.data.tags}
category={entry.data.category}
published={entry.data.published}
updated={entry.data.updated}
url={getPostUrlBySlug(entry.slug)}
image={entry.data.image}
description={entry.data.description}
draft={entry.data.draft}
class:list="onload-animation"
style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`}
></PostCard>
))}
</div>

View File

@@ -0,0 +1,198 @@
<script lang="ts">
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import { url } from "@utils/url-utils.ts";
import { onMount } from "svelte";
import type { SearchResult } from "@/global";
let keywordDesktop = "";
let keywordMobile = "";
let result: SearchResult[] = [];
let isSearching = false;
let pagefindLoaded = false;
let initialized = false;
const fakeResult: SearchResult[] = [
{
url: url("/"),
meta: {
title: "This Is a Fake Search Result",
},
excerpt:
"Because the search cannot work in the <mark>dev</mark> environment.",
},
{
url: url("/"),
meta: {
title: "If You Want to Test the Search",
},
excerpt: "Try running <mark>npm build && npm preview</mark> instead.",
},
];
const togglePanel = () => {
const panel = document.getElementById("search-panel");
panel?.classList.toggle("float-panel-closed");
};
const setPanelVisibility = (show: boolean, isDesktop: boolean): void => {
const panel = document.getElementById("search-panel");
if (!panel || !isDesktop) return;
if (show) {
panel.classList.remove("float-panel-closed");
} else {
panel.classList.add("float-panel-closed");
}
};
const search = async (keyword: string, isDesktop: boolean): Promise<void> => {
if (!keyword) {
setPanelVisibility(false, isDesktop);
result = [];
return;
}
if (!initialized) {
return;
}
isSearching = true;
try {
let searchResults: SearchResult[] = [];
if (import.meta.env.PROD && pagefindLoaded && window.pagefind) {
const response = await window.pagefind.search(keyword);
searchResults = await Promise.all(
response.results.map((item) => item.data()),
);
} else if (import.meta.env.DEV) {
searchResults = fakeResult;
} else {
searchResults = [];
console.error("Pagefind is not available in production environment.");
}
result = searchResults;
setPanelVisibility(result.length > 0, isDesktop);
} catch (error) {
console.error("Search error:", error);
result = [];
setPanelVisibility(false, isDesktop);
} finally {
isSearching = false;
}
};
onMount(() => {
const initializeSearch = () => {
initialized = true;
pagefindLoaded =
typeof window !== "undefined" &&
!!window.pagefind &&
typeof window.pagefind.search === "function";
console.log("Pagefind status on init:", pagefindLoaded);
if (keywordDesktop) search(keywordDesktop, true);
if (keywordMobile) search(keywordMobile, false);
};
if (import.meta.env.DEV) {
console.log(
"Pagefind is not available in development mode. Using mock data.",
);
initializeSearch();
} else {
document.addEventListener("pagefindready", () => {
console.log("Pagefind ready event received.");
initializeSearch();
});
document.addEventListener("pagefindloaderror", () => {
console.warn(
"Pagefind load error event received. Search functionality will be limited.",
);
initializeSearch(); // Initialize with pagefindLoaded as false
});
// Fallback in case events are not caught or pagefind is already loaded by the time this script runs
setTimeout(() => {
if (!initialized) {
console.log("Fallback: Initializing search after timeout.");
initializeSearch();
}
}, 2000); // Adjust timeout as needed
}
});
$: if (initialized && keywordDesktop) {
(async () => {
await search(keywordDesktop, true);
})();
}
$: if (initialized && keywordMobile) {
(async () => {
await search(keywordMobile, false);
})();
}
</script>
<!-- search bar for desktop view -->
<div id="search-bar" class="hidden lg:flex transition-all items-center h-11 mr-2 rounded-lg
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder="{i18n(I18nKey.search)}" bind:value={keywordDesktop} on:focus={() => search(keywordDesktop, true)}
class="transition-all pl-10 text-sm bg-transparent outline-0
h-full w-40 active:w-60 focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- toggle btn for phone/tablet view -->
<button on:click={togglePanel} aria-label="Search Panel" id="search-switch"
class="btn-plain scale-animation lg:!hidden rounded-lg w-11 h-11 active:scale-90">
<Icon icon="material-symbols:search" class="text-[1.25rem]"></Icon>
</button>
<!-- search panel -->
<div id="search-panel" class="float-panel float-panel-closed search-panel absolute md:w-[30rem]
top-20 left-4 md:left-[unset] right-4 shadow-2xl rounded-2xl p-2">
<!-- search bar inside panel for phone/tablet -->
<div id="search-bar-inside" class="flex relative lg:hidden transition-all items-center h-11 rounded-xl
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder="Search" bind:value={keywordMobile}
class="pl-10 absolute inset-0 text-sm bg-transparent outline-0
focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- search results -->
{#each result as item}
<a href={item.url}
class="transition first-of-type:mt-2 lg:first-of-type:mt-0 group block
rounded-xl text-lg px-3 py-2 hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]">
<div class="transition text-90 inline-flex font-bold group-hover:text-[var(--primary)]">
{item.meta.title}<Icon icon="fa6-solid:chevron-right" class="transition text-[0.75rem] translate-x-1 my-auto text-[var(--primary)]"></Icon>
</div>
<div class="transition text-sm text-50">
{@html item.excerpt}
</div>
</a>
{/each}
</div>
<style>
input:focus {
outline: 0;
}
.search-panel {
max-height: calc(100vh - 100px);
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,49 @@
---
import { Icon } from "astro-icon/components";
---
<!-- There can't be a filter on parent element, or it will break `fixed` -->
<div class="back-to-top-wrapper hidden lg:block">
<div id="back-to-top-btn" class="back-to-top-btn hide flex items-center rounded-2xl overflow-hidden transition" onclick="backToTop()">
<button aria-label="Back to Top" class="btn-card h-[3.75rem] w-[3.75rem]">
<Icon name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
</button>
</div>
</div>
<style lang="stylus">
.back-to-top-wrapper
width: 3.75rem
height: 3.75rem
position: absolute
right: 0
top: 0
pointer-events: none
.back-to-top-btn
color: var(--primary)
font-size: 2.25rem
font-weight: bold
border: none
position: fixed
bottom: 10rem
opacity: 1
cursor: pointer
transform: translateX(5rem)
pointer-events: auto
i
font-size: 1.75rem
&.hide
transform: translateX(5rem) scale(0.9)
opacity: 0
pointer-events: none
&:active
transform: translateX(5rem) scale(0.9)
</style>
<script is:raw is:inline>
function backToTop() {
window.scroll({ top: 0, behavior: 'smooth' });
}
</script>

View File

@@ -0,0 +1,43 @@
---
interface Props {
badge?: string;
url?: string;
label?: string;
}
const { badge, url, label } = Astro.props;
---
<a href={url} aria-label={label}>
<button
class:list={`
w-full
h-10
rounded-lg
bg-none
hover:bg-[var(--btn-plain-bg-hover)]
active:bg-[var(--btn-plain-bg-active)]
transition-all
pl-2
hover:pl-3
text-neutral-700
hover:text-[var(--primary)]
dark:text-neutral-300
dark:hover:text-[var(--primary)]
`
}
>
<div class="flex items-center justify-between relative mr-2">
<div class="overflow-hidden text-left whitespace-nowrap overflow-ellipsis ">
<slot></slot>
</div>
{ badge !== undefined && badge !== null && badge !== '' &&
<div class="transition px-2 h-7 ml-4 min-w-[2rem] rounded-lg text-sm font-bold
text-[var(--btn-content)] dark:text-[var(--deep-text)]
bg-[oklch(0.95_0.025_var(--hue))] dark:bg-[var(--primary)]
flex items-center justify-center">
{ badge }
</div>
}
</div>
</button>
</a>

View File

@@ -0,0 +1,13 @@
---
interface Props {
size?: string;
dot?: boolean;
href?: string;
label?: string;
}
const { dot, href, label }: Props = Astro.props;
---
<a href={href} aria-label={label} class="btn-regular h-8 text-sm px-3 rounded-lg">
{dot && <div class="h-1 w-1 bg-[var(--btn-content)] dark:bg-[var(--card-bg)] transition rounded-md mr-2"></div>}
<slot></slot>
</a>

View File

@@ -0,0 +1,84 @@
---
import type { Page } from "astro";
import { Icon } from "astro-icon/components";
import { url } from "../../utils/url-utils";
interface Props {
page: Page;
class?: string;
style?: string;
}
const { page, style } = Astro.props;
const HIDDEN = -1;
const className = Astro.props.class;
const ADJ_DIST = 2;
const VISIBLE = ADJ_DIST * 2 + 1;
// for test
let count = 1;
let l = page.currentPage;
let r = page.currentPage;
while (0 < l - 1 && r + 1 <= page.lastPage && count + 2 <= VISIBLE) {
count += 2;
l--;
r++;
}
while (0 < l - 1 && count < VISIBLE) {
count++;
l--;
}
while (r + 1 <= page.lastPage && count < VISIBLE) {
count++;
r++;
}
let pages: number[] = [];
if (l > 1) pages.push(1);
if (l === 3) pages.push(2);
if (l > 3) pages.push(HIDDEN);
for (let i = l; i <= r; i++) pages.push(i);
if (r < page.lastPage - 2) pages.push(HIDDEN);
if (r === page.lastPage - 2) pages.push(page.lastPage - 1);
if (r < page.lastPage) pages.push(page.lastPage);
const getPageUrl = (p: number) => {
if (p === 1) return "/";
return `/${p}/`;
};
---
<div class:list={[className, "flex flex-row gap-3 justify-center"]} style={style}>
<a href={page.url.prev || ""} aria-label={page.url.prev ? "Previous Page" : null}
class:list={["btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
{"disabled": page.url.prev == undefined}
]}
>
<Icon name="material-symbols:chevron-left-rounded" class="text-[1.75rem]"></Icon>
</a>
<div class="bg-[var(--card-bg)] flex flex-row rounded-lg items-center text-neutral-700 dark:text-neutral-300 font-bold">
{pages.map((p) => {
if (p == HIDDEN)
return <Icon name="material-symbols:more-horiz" class="mx-1"/>;
if (p == page.currentPage)
return <div class="h-11 w-11 rounded-lg bg-[var(--primary)] flex items-center justify-center
font-bold text-white dark:text-black/70"
>
{p}
</div>
return <a href={url(getPageUrl(p))} aria-label={`Page ${p}`}
class="btn-card w-11 h-11 rounded-lg overflow-hidden active:scale-[0.85]"
>{p}</a>
})}
</div>
<a href={page.url.next || ""} aria-label={page.url.next ? "Next Page" : null}
class:list={["btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
{"disabled": page.url.next == undefined}
]}
>
<Icon name="material-symbols:chevron-right-rounded" class="text-[1.75rem]"></Icon>
</a>
</div>

View File

@@ -0,0 +1,54 @@
---
import path from "node:path";
interface Props {
id?: string;
src: string;
class?: string;
alt?: string;
position?: string;
basePath?: string;
}
import { Image } from "astro:assets";
import { url } from "../../utils/url-utils";
const { id, src, alt, position = "center", basePath = "/" } = Astro.props;
const className = Astro.props.class;
const isLocal = !(
src.startsWith("/") ||
src.startsWith("http") ||
src.startsWith("https") ||
src.startsWith("data:")
);
const isPublic = src.startsWith("/");
// TODO temporary workaround for images dynamic import
// https://github.com/withastro/astro/issues/3373
// biome-ignore lint/suspicious/noImplicitAnyLet: <check later>
let img;
if (isLocal) {
const files = import.meta.glob<ImageMetadata>("../../**", {
import: "default",
});
let normalizedPath = path
.normalize(path.join("../../", basePath, src))
.replace(/\\/g, "/");
const file = files[normalizedPath];
if (!file) {
console.error(
`\n[ERROR] Image file not found: ${normalizedPath.replace("../../", "src/")}`,
);
}
img = await file();
}
const imageClass = "w-full h-full object-cover";
const imageStyle = `object-position: ${position}`;
---
<div id={id} class:list={[className, 'overflow-hidden relative']}>
<div class="transition absolute inset-0 dark:bg-black/10 bg-opacity-50 pointer-events-none"></div>
{isLocal && img && <Image src={img} alt={alt || ""} class={imageClass} style={imageStyle}/>}
{!isLocal && <img src={isPublic ? url(src) : src} alt={alt || ""} class={imageClass} style={imageStyle}/>}
</div>

View File

@@ -0,0 +1,43 @@
---
import { Icon } from "astro-icon/components";
import { licenseConfig, profileConfig } from "../../config";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { formatDateToYYYYMMDD } from "../../utils/date-utils";
interface Props {
title: string;
slug: string;
pubDate: Date;
class: string;
}
const { title, pubDate } = Astro.props;
const className = Astro.props.class;
const profileConf = profileConfig;
const licenseConf = licenseConfig;
const postUrl = decodeURIComponent(Astro.url.toString());
---
<div class={`relative transition overflow-hidden bg-[var(--license-block-bg)] py-5 px-6 ${className}`}>
<div class="transition font-bold text-black/75 dark:text-white/75">
{title}
</div>
<a href={postUrl} class="link text-[var(--primary)]">
{postUrl}
</a>
<div class="flex gap-6 mt-2">
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.author)}</div>
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">{profileConf.name}</div>
</div>
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.publishedAt)}</div>
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">{formatDateToYYYYMMDD(pubDate)}</div>
</div>
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.license)}</div>
<a href={licenseConf.url} target="_blank" class="link text-[var(--primary)] line-clamp-2">{licenseConf.name}</a>
</div>
</div>
<Icon name="fa6-brands:creative-commons" class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"></Icon>
</div>

View File

@@ -0,0 +1,43 @@
---
import "@fontsource-variable/jetbrains-mono";
import "@fontsource-variable/jetbrains-mono/wght-italic.css";
interface Props {
class: string;
}
const className = Astro.props.class;
---
<div data-pagefind-body class={`prose dark:prose-invert prose-base !max-w-none custom-md ${className}`}>
<!--<div class="prose dark:prose-invert max-w-none custom-md">-->
<!--<div class="max-w-none custom-md">-->
<slot/>
</div>
<script>
document.addEventListener("click", function (e: MouseEvent) {
const target = e.target as Element | null;
if (target && target.classList.contains("copy-btn")) {
const preEle = target.closest("pre");
const codeEle = preEle?.querySelector("code");
const code = Array.from(codeEle?.querySelectorAll(".code:not(summary *)") ?? [])
.map(el => el.textContent)
.map(t => t === "\n" ? "" : t)
.join("\n");
navigator.clipboard.writeText(code);
const timeoutId = target.getAttribute("data-timeout-id");
if (timeoutId) {
clearTimeout(parseInt(timeoutId));
}
target.classList.add("success");
// 设置新的timeout并保存ID到按钮的自定义属性中
const newTimeoutId = setTimeout(() => {
target.classList.remove("success");
}, 1000);
target.setAttribute("data-timeout-id", newTimeoutId.toString());
}
});
</script>

View File

@@ -0,0 +1,35 @@
---
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { getCategoryList } from "../../utils/content-utils";
import ButtonLink from "../control/ButtonLink.astro";
import WidgetLayout from "./WidgetLayout.astro";
const categories = await getCategoryList();
const COLLAPSED_HEIGHT = "7.5rem";
const COLLAPSE_THRESHOLD = 5;
const isCollapsed = categories.length >= COLLAPSE_THRESHOLD;
interface Props {
class?: string;
style?: string;
}
const className = Astro.props.class;
const style = Astro.props.style;
---
<WidgetLayout name={i18n(I18nKey.categories)} id="categories" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT}
class={className} style={style}
>
{categories.map((c) =>
<ButtonLink
url={c.url}
badge={String(c.count)}
label={`View all posts in the ${c.name.trim()} category`}
>
{c.name.trim()}
</ButtonLink>
)}
</WidgetLayout>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import { getDefaultHue, getHue, setHue } from "@utils/setting-utils";
let hue = getHue();
const defaultHue = getDefaultHue();
function resetHue() {
hue = getDefaultHue();
}
$: if (hue || hue === 0) {
setHue(hue);
}
</script>
<div id="display-setting" class="float-panel float-panel-closed absolute transition-all w-80 right-4 px-4 py-4">
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
{i18n(I18nKey.themeColor)}
<button aria-label="Reset to Default" class="btn-regular w-7 h-7 rounded-md active:scale-90"
class:opacity-0={hue === defaultHue} class:pointer-events-none={hue === defaultHue} on:click={resetHue}>
<div class="text-[var(--btn-content)]">
<Icon icon="fa6-solid:arrow-rotate-left" class="text-[0.875rem]"></Icon>
</div>
</button>
</div>
<div class="flex gap-1">
<div id="hueValue" class="transition bg-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
font-bold text-sm items-center text-[var(--btn-content)]">
{hue}
</div>
</div>
</div>
<div class="w-full h-6 px-1 bg-[oklch(0.80_0.10_0)] dark:bg-[oklch(0.70_0.10_0)] rounded select-none">
<input aria-label={i18n(I18nKey.themeColor)} type="range" min="0" max="360" bind:value={hue}
class="slider" id="colorSlider" step="5" style="width: 100%">
</div>
</div>
<style lang="stylus">
#display-setting
input[type="range"]
-webkit-appearance none
height 1.5rem
background-image var(--color-selection-bar)
transition background-image 0.15s ease-in-out
/* Input Thumb */
&::-webkit-slider-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-moz-range-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
border-width 0
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-ms-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
</style>

View File

@@ -0,0 +1,32 @@
---
import { Icon } from "astro-icon/components";
import { type NavBarLink } from "../../types/config";
import { url } from "../../utils/url-utils";
interface Props {
links: NavBarLink[];
}
const links = Astro.props.links;
---
<div id="nav-menu-panel" class:list={["float-panel float-panel-closed absolute transition-all fixed right-4 px-2 py-2"]}>
{links.map((link) => (
<a href={link.external ? link.url : url(link.url)} class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8
hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition
"
target={link.external ? "_blank" : null}
>
<div class="transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
{link.name}
</div>
{!link.external && <Icon name="material-symbols:chevron-right-rounded"
class="transition text-[1.25rem] text-[var(--primary)]"
>
</Icon>}
{link.external && <Icon name="fa6-solid:arrow-up-right-from-square"
class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1"
>
</Icon>}
</a>
))}
</div>

View File

@@ -0,0 +1,39 @@
---
import { Icon } from "astro-icon/components";
import { profileConfig } from "../../config";
import { url } from "../../utils/url-utils";
import ImageWrapper from "../misc/ImageWrapper.astro";
const config = profileConfig;
---
<div class="card-base p-3">
<a aria-label="Go to About Page" href={url('/about/')}
class="group block relative mx-auto mt-1 lg:mx-0 lg:mt-0 mb-3
max-w-[12rem] lg:max-w-none overflow-hidden rounded-xl active:scale-95">
<div class="absolute transition pointer-events-none group-hover:bg-black/30 group-active:bg-black/50
w-full h-full z-50 flex items-center justify-center">
<Icon name="fa6-regular:address-card"
class="transition opacity-0 scale-90 group-hover:scale-100 group-hover:opacity-100 text-white text-5xl">
</Icon>
</div>
<ImageWrapper src={config.avatar || ""} alt="Profile Image of the Author" class="mx-auto lg:w-full h-full lg:mt-0 "></ImageWrapper>
</a>
<div class="px-2">
<div class="font-bold text-xl text-center mb-1 dark:text-neutral-50 transition">{config.name}</div>
<div class="h-1 w-5 bg-[var(--primary)] mx-auto rounded-full mb-2 transition"></div>
<div class="text-center text-neutral-400 mb-2.5 transition">{config.bio}</div>
<div class="flex gap-2 justify-center mb-1">
{config.links.length > 1 && config.links.map(item =>
<a rel="me" aria-label={item.name} href={item.url} target="_blank" class="btn-regular rounded-lg h-10 w-10 active:scale-90">
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
</a>
)}
{config.links.length == 1 && <a rel="me" aria-label={config.links[0].name} href={config.links[0].url} target="_blank"
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
<Icon name={config.links[0].icon} class="text-[1.5rem]"></Icon>
{config.links[0].name}
</a>}
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
---
import type { MarkdownHeading } from "astro";
import Categories from "./Categories.astro";
import Profile from "./Profile.astro";
import Tag from "./Tags.astro";
interface Props {
class?: string;
headings?: MarkdownHeading[];
}
const className = Astro.props.class;
---
<div id="sidebar" class:list={[className, "w-full"]}>
<div class="flex flex-col w-full gap-4 mb-4">
<Profile></Profile>
</div>
<div id="sidebar-sticky" class="transition-all duration-700 flex flex-col w-full gap-4 top-4 sticky top-4">
<Categories class="onload-animation" style="animation-delay: 150ms"></Categories>
<Tag class="onload-animation" style="animation-delay: 200ms"></Tag>
</div>
</div>

View File

@@ -0,0 +1,264 @@
---
import type { MarkdownHeading } from "astro";
import { siteConfig } from "../../config";
interface Props {
class?: string;
headings: MarkdownHeading[];
}
let { headings = [] } = Astro.props;
let minDepth = 10;
for (const heading of headings) {
minDepth = Math.min(minDepth, heading.depth);
}
const className = Astro.props.class;
const isPostsRoute = Astro.url.pathname.startsWith("/posts/");
const removeTailingHash = (text: string) => {
let lastIndexOfHash = text.lastIndexOf("#");
if (lastIndexOfHash !== text.length - 1) {
return text;
}
return text.substring(0, lastIndexOfHash);
};
let heading1Count = 1;
const maxLevel = siteConfig.toc.depth;
---
{isPostsRoute &&
<table-of-contents class:list={[className, "group"]}>
{headings.filter((heading) => heading.depth < minDepth + maxLevel).map((heading) =>
<a href={`#${heading.slug}`} class="px-2 flex gap-2 relative transition w-full min-h-9 rounded-xl
hover:bg-[var(--toc-btn-hover)] active:bg-[var(--toc-btn-active)] py-2
">
<div class:list={["transition w-5 h-5 shrink-0 rounded-lg text-xs flex items-center justify-center font-bold",
{
"bg-[var(--toc-badge-bg)] text-[var(--btn-content)]": heading.depth == minDepth,
"ml-4": heading.depth == minDepth + 1,
"ml-8": heading.depth == minDepth + 2,
}
]}
>
{heading.depth == minDepth && heading1Count++}
{heading.depth == minDepth + 1 && <div class="transition w-2 h-2 rounded-[0.1875rem] bg-[var(--toc-badge-bg)]"></div>}
{heading.depth == minDepth + 2 && <div class="transition w-1.5 h-1.5 rounded-sm bg-black/5 dark:bg-white/10"></div>}
</div>
<div class:list={["transition text-sm", {
"text-50": heading.depth == minDepth || heading.depth == minDepth + 1,
"text-30": heading.depth == minDepth + 2,
}]}>{removeTailingHash(heading.text)}</div>
</a>
)}
<div id="active-indicator" class:list={[{'hidden': headings.length == 0}, "-z-10 absolute bg-[var(--toc-btn-hover)] left-0 right-0 rounded-xl transition-all " +
"group-hover:bg-transparent border-2 border-[var(--toc-btn-hover)] group-hover:border-[var(--toc-btn-active)] border-dashed"]}></div>
</table-of-contents>}
<script>
class TableOfContents extends HTMLElement {
tocEl: HTMLElement | null = null;
visibleClass = "visible";
observer: IntersectionObserver;
anchorNavTarget: HTMLElement | null = null;
headingIdxMap = new Map<string, number>();
headings: HTMLElement[] = [];
sections: HTMLElement[] = [];
tocEntries: HTMLAnchorElement[] = [];
active: boolean[] = [];
activeIndicator: HTMLElement | null = null;
constructor() {
super();
this.observer = new IntersectionObserver(
this.markVisibleSection, { threshold: 0 }
);
};
markVisibleSection = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
const id = entry.target.children[0]?.getAttribute("id");
const idx = id ? this.headingIdxMap.get(id) : undefined;
if (idx != undefined)
this.active[idx] = entry.isIntersecting;
if (entry.isIntersecting && this.anchorNavTarget == entry.target.firstChild)
this.anchorNavTarget = null;
});
if (!this.active.includes(true))
this.fallback();
this.update();
};
toggleActiveHeading = () => {
let i = this.active.length - 1;
let min = this.active.length - 1, max = 0;
while (i >= 0 && !this.active[i]) {
this.tocEntries[i].classList.remove(this.visibleClass);
i--;
}
while (i >= 0 && this.active[i]) {
this.tocEntries[i].classList.add(this.visibleClass);
min = Math.min(min, i);
max = Math.max(max, i);
i--;
}
while (i >= 0) {
this.tocEntries[i].classList.remove(this.visibleClass);
i--;
}
let parentOffset = this.tocEl?.getBoundingClientRect().top || 0;
let scrollOffset = this.tocEl?.scrollTop || 0;
let top = this.tocEntries[min].getBoundingClientRect().top - parentOffset + scrollOffset;
let bottom = this.tocEntries[max].getBoundingClientRect().bottom - parentOffset + scrollOffset;
this.activeIndicator?.setAttribute("style", `top: ${top}px; height: ${bottom - top}px`);
};
scrollToActiveHeading = () => {
// If the TOC widget can accommodate both the topmost
// and bottommost items, scroll to the topmost item.
// Otherwise, scroll to the bottommost one.
if (this.anchorNavTarget || !this.tocEl) return;
const activeHeading =
document.querySelectorAll<HTMLDivElement>(`#toc .${this.visibleClass}`);
if (!activeHeading.length) return;
const topmost = activeHeading[0];
const bottommost = activeHeading[activeHeading.length - 1];
const tocHeight = this.tocEl.clientHeight;
let top;
if (bottommost.getBoundingClientRect().bottom -
topmost.getBoundingClientRect().top < 0.9 * tocHeight)
top = topmost.offsetTop - 32;
else
top = bottommost.offsetTop - tocHeight * 0.8;
this.tocEl.scrollTo({
top,
left: 0,
behavior: "smooth",
});
};
update = () => {
requestAnimationFrame(() => {
this.toggleActiveHeading();
// requestAnimationFrame(() => {
this.scrollToActiveHeading();
// });
});
};
fallback = () => {
if (!this.sections.length) return;
for (let i = 0; i < this.sections.length; i++) {
let offsetTop = this.sections[i].getBoundingClientRect().top;
let offsetBottom = this.sections[i].getBoundingClientRect().bottom;
if (this.isInRange(offsetTop, 0, window.innerHeight)
|| this.isInRange(offsetBottom, 0, window.innerHeight)
|| (offsetTop < 0 && offsetBottom > window.innerHeight)) {
this.markActiveHeading(i);
}
else if (offsetTop > window.innerHeight) break;
}
};
markActiveHeading = (idx: number)=> {
this.active[idx] = true;
};
handleAnchorClick = (event: Event) => {
const anchor = event
.composedPath()
.find((element) => element instanceof HTMLAnchorElement);
if (anchor) {
const id = decodeURIComponent(anchor.hash?.substring(1));
const idx = this.headingIdxMap.get(id);
if (idx !== undefined) {
this.anchorNavTarget = this.headings[idx];
} else {
this.anchorNavTarget = null;
}
}
};
isInRange(value: number, min: number, max: number) {
return min < value && value < max;
};
connectedCallback() {
// wait for the onload animation to finish, which makes the `getBoundingClientRect` return correct values
const element = document.querySelector('.prose');
if (element) {
element.addEventListener('animationend', () => {
this.init();
}, { once: true });
} else {
console.debug('Animation element not found');
}
};
init() {
this.tocEl = document.getElementById(
"toc-inner-wrapper"
);
if (!this.tocEl) return;
this.tocEl.addEventListener("click", this.handleAnchorClick, {
capture: true,
});
this.activeIndicator = document.getElementById("active-indicator");
this.tocEntries = Array.from(
document.querySelectorAll<HTMLAnchorElement>("#toc a[href^='#']")
);
if (this.tocEntries.length === 0) return;
this.sections = new Array(this.tocEntries.length);
this.headings = new Array(this.tocEntries.length);
for (let i = 0; i < this.tocEntries.length; i++) {
const id = decodeURIComponent(this.tocEntries[i].hash?.substring(1));
const heading = document.getElementById(id);
const section = heading?.parentElement;
if (heading instanceof HTMLElement && section instanceof HTMLElement) {
this.headings[i] = heading;
this.sections[i] = section;
this.headingIdxMap.set(id, i);
}
}
this.active = new Array(this.tocEntries.length).fill(false);
this.sections.forEach((section) =>
this.observer.observe(section)
);
this.fallback();
this.update();
};
disconnectedCallback() {
this.sections.forEach((section) =>
this.observer.unobserve(section)
);
this.observer.disconnect();
this.tocEl?.removeEventListener("click", this.handleAnchorClick);
};
}
if (!customElements.get("table-of-contents")) {
customElements.define("table-of-contents", TableOfContents);
}
</script>

View File

@@ -0,0 +1,31 @@
---
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { getTagList } from "../../utils/content-utils";
import { getTagUrl } from "../../utils/url-utils";
import ButtonTag from "../control/ButtonTag.astro";
import WidgetLayout from "./WidgetLayout.astro";
const tags = await getTagList();
const COLLAPSED_HEIGHT = "7.5rem";
const isCollapsed = tags.length >= 20;
interface Props {
class?: string;
style?: string;
}
const className = Astro.props.class;
const style = Astro.props.style;
---
<WidgetLayout name={i18n(I18nKey.tags)} id="tags" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT} class={className} style={style}>
<div class="flex gap-2 flex-wrap">
{tags.map(t => (
<ButtonTag href={getTagUrl(t.name)} label={`View all posts with the ${t.name.trim()} tag`}>
{t.name.trim()}
</ButtonTag>
))}
</div>
</WidgetLayout>

View File

@@ -0,0 +1,60 @@
---
import { Icon } from "astro-icon/components";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
interface Props {
id: string;
name?: string;
isCollapsed?: boolean;
collapsedHeight?: string;
class?: string;
style?: string;
}
const { id, name, isCollapsed, collapsedHeight, style } = Astro.props;
const className = Astro.props.class;
---
<widget-layout data-id={id} data-is-collapsed={String(isCollapsed)} class={"pb-4 card-base " + className} style={style}>
<div class="font-bold transition text-lg text-neutral-900 dark:text-neutral-100 relative ml-8 mt-4 mb-2
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:left-[-16px] before:top-[5.5px]">{name}</div>
<div id={id} class:list={["collapse-wrapper px-4 overflow-hidden", {"collapsed": isCollapsed}]}>
<slot></slot>
</div>
{isCollapsed && <div class="expand-btn px-4 -mb-2">
<button class="btn-plain rounded-lg w-full h-9">
<div class="text-[var(--primary)] flex items-center justify-center gap-2 -translate-x-2">
<Icon name="material-symbols:more-horiz" class="text-[1.75rem]"></Icon> {i18n(I18nKey.more)}
</div>
</button>
</div>}
</widget-layout>
<style define:vars={{ collapsedHeight }}>
.collapsed {
height: var(--collapsedHeight);
}
</style>
<script>
class WidgetLayout extends HTMLElement {
constructor() {
super();
if (this.dataset.isCollapsed !== "true")
return;
const id = this.dataset.id;
const btn = this.querySelector('.expand-btn');
const wrapper = this.querySelector(`#${id}`)
btn!.addEventListener('click', () => {
wrapper!.classList.remove('collapsed');
btn!.classList.add('hidden');
})
}
}
if (!customElements.get("widget-layout")) {
customElements.define("widget-layout", WidgetLayout);
}
</script>

90
src/config.ts Normal file
View File

@@ -0,0 +1,90 @@
import type {
ExpressiveCodeConfig,
LicenseConfig,
NavBarConfig,
ProfileConfig,
SiteConfig,
} from "./types/config";
import { LinkPreset } from "./types/config";
export const siteConfig: SiteConfig = {
title: "Fuwari",
subtitle: "Demo Site",
lang: "en", // 'en', 'zh_CN', 'zh_TW', 'ja', 'ko', 'es', 'th'
themeColor: {
hue: 250, // Default hue for the theme color, from 0 to 360. e.g. red: 0, teal: 200, cyan: 250, pink: 345
fixed: false, // Hide the theme color picker for visitors
},
banner: {
enable: false,
src: "assets/images/demo-banner.png", // Relative to the /src directory. Relative to the /public directory if it starts with '/'
position: "center", // Equivalent to object-position, only supports 'top', 'center', 'bottom'. 'center' by default
credit: {
enable: false, // Display the credit text of the banner image
text: "", // Credit text to be displayed
url: "", // (Optional) URL link to the original artwork or artist's page
},
},
toc: {
enable: true, // Display the table of contents on the right side of the post
depth: 2, // Maximum heading depth to show in the table, from 1 to 3
},
favicon: [
// Leave this array empty to use the default favicon
// {
// src: '/favicon/icon.png', // Path of the favicon, relative to the /public directory
// theme: 'light', // (Optional) Either 'light' or 'dark', set only if you have different favicons for light and dark mode
// sizes: '32x32', // (Optional) Size of the favicon, set only if you have favicons of different sizes
// }
],
};
export const navBarConfig: NavBarConfig = {
links: [
LinkPreset.Home,
LinkPreset.Archive,
LinkPreset.About,
{
name: "GitHub",
url: "https://github.com/saicaca/fuwari", // Internal links should not include the base path, as it is automatically added
external: true, // Show an external link icon and will open in a new tab
},
],
};
export const profileConfig: ProfileConfig = {
avatar: "assets/images/demo-avatar.png", // Relative to the /src directory. Relative to the /public directory if it starts with '/'
name: "Lorem Ipsum",
bio: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
links: [
{
name: "Twitter",
icon: "fa6-brands:twitter", // Visit https://icones.js.org/ for icon codes
// You will need to install the corresponding icon set if it's not already included
// `pnpm add @iconify-json/<icon-set-name>`
url: "https://twitter.com",
},
{
name: "Steam",
icon: "fa6-brands:steam",
url: "https://store.steampowered.com",
},
{
name: "GitHub",
icon: "fa6-brands:github",
url: "https://github.com/saicaca/fuwari",
},
],
};
export const licenseConfig: LicenseConfig = {
enable: true,
name: "CC BY-NC-SA 4.0",
url: "https://creativecommons.org/licenses/by-nc-sa/4.0/",
};
export const expressiveCodeConfig: ExpressiveCodeConfig = {
// Note: Some styles (such as background color) are being overridden, see the astro.config.mjs file.
// Please select a dark theme, as this blog theme currently only supports dark background color
theme: "github-dark",
};

View File

@@ -0,0 +1,17 @@
export const PAGE_SIZE = 8;
export const LIGHT_MODE = "light",
DARK_MODE = "dark",
AUTO_MODE = "auto";
export const DEFAULT_THEME = AUTO_MODE;
// Banner height unit: vh
export const BANNER_HEIGHT = 35;
export const BANNER_HEIGHT_EXTEND = 30;
export const BANNER_HEIGHT_HOME = BANNER_HEIGHT + BANNER_HEIGHT_EXTEND;
// The height the main panel overlaps the banner, unit: rem
export const MAIN_PANEL_OVERLAPS_BANNER_HEIGHT = 3.5;
// Page width: rem
export const PAGE_WIDTH = 75;

44
src/constants/icon.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { Favicon } from "@/types/config.ts";
export const defaultFavicons: Favicon[] = [
{
src: "/favicon/favicon-light-32.png",
theme: "light",
sizes: "32x32",
},
{
src: "/favicon/favicon-light-128.png",
theme: "light",
sizes: "128x128",
},
{
src: "/favicon/favicon-light-180.png",
theme: "light",
sizes: "180x180",
},
{
src: "/favicon/favicon-light-192.png",
theme: "light",
sizes: "192x192",
},
{
src: "/favicon/favicon-dark-32.png",
theme: "dark",
sizes: "32x32",
},
{
src: "/favicon/favicon-dark-128.png",
theme: "dark",
sizes: "128x128",
},
{
src: "/favicon/favicon-dark-180.png",
theme: "dark",
sizes: "180x180",
},
{
src: "/favicon/favicon-dark-192.png",
theme: "dark",
sizes: "192x192",
},
];

View File

@@ -0,0 +1,18 @@
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import { LinkPreset, type NavBarLink } from "@/types/config";
export const LinkPresets: { [key in LinkPreset]: NavBarLink } = {
[LinkPreset.Home]: {
name: i18n(I18nKey.home),
url: "/",
},
[LinkPreset.About]: {
name: i18n(I18nKey.about),
url: "/about/",
},
[LinkPreset.Archive]: {
name: i18n(I18nKey.archive),
url: "/archive/",
},
};

24
src/content/config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineCollection, z } from "astro:content";
const postsCollection = defineCollection({
schema: z.object({
title: z.string(),
published: z.date(),
updated: z.date().optional(),
draft: z.boolean().optional().default(false),
description: z.string().optional().default(""),
image: z.string().optional().default(""),
tags: z.array(z.string()).optional().default([]),
category: z.string().optional().nullable().default(""),
lang: z.string().optional().default(""),
/* For internal use */
prevTitle: z.string().default(""),
prevSlug: z.string().default(""),
nextTitle: z.string().default(""),
nextSlug: z.string().default(""),
}),
});
export const collections = {
posts: postsCollection,
};

View File

@@ -0,0 +1,22 @@
---
title: Draft Example
published: 2022-07-01
tags: [Markdown, Blogging, Demo]
category: Examples
draft: true
---
# This Article is a Draft
This article is currently in a draft state and is not published. Therefore, it will not be visible to the general audience. The content is still a work in progress and may require further editing and review.
When the article is ready for publication, you can update the "draft" field to "false" in the Frontmatter:
```markdown
---
title: Draft Example
published: 2024-01-11T04:40:26.381Z
tags: [Markdown, Blogging, Demo]
category: Examples
draft: false
---

View File

@@ -0,0 +1,311 @@
---
title: Expressive Code Example
published: 2024-04-10
description: How code blocks look in Markdown using Expressive Code.
tags: [Markdown, Blogging, Demo]
category: Examples
draft: false
---
Here, we'll explore how code blocks look using [Expressive Code](https://expressive-code.com/). The provided examples are based on the official documentation, which you can refer to for further details.
## Expressive Code
### Syntax Highlighting
[Syntax Highlighting](https://expressive-code.com/key-features/syntax-highlighting/)
#### Regular syntax highlighting
```js
console.log('This code is syntax highlighted!')
```
#### Rendering ANSI escape sequences
```ansi
ANSI colors:
- Regular: Red Green Yellow Blue Magenta Cyan
- Bold: Red Green Yellow Blue Magenta Cyan
- Dimmed: Red Green Yellow Blue Magenta Cyan
256 colors (showing colors 160-177):
160 161 162 163 164 165
166 167 168 169 170 171
172 173 174 175 176 177
Full RGB colors:
ForestGreen - RGB(34, 139, 34)
Text formatting: Bold Dimmed Italic Underline
```
### Editor & Terminal Frames
[Editor & Terminal Frames](https://expressive-code.com/key-features/frames/)
#### Code editor frames
```js title="my-test-file.js"
console.log('Title attribute example')
```
---
```html
<!-- src/content/index.html -->
<div>File name comment example</div>
```
#### Terminal frames
```bash
echo "This terminal frame has no title"
```
---
```powershell title="PowerShell terminal example"
Write-Output "This one has a title!"
```
#### Overriding frame types
```sh frame="none"
echo "Look ma, no frame!"
```
---
```ps frame="code" title="PowerShell Profile.ps1"
# Without overriding, this would be a terminal frame
function Watch-Tail { Get-Content -Tail 20 -Wait $args }
New-Alias tail Watch-Tail
```
### Text & Line Markers
[Text & Line Markers](https://expressive-code.com/key-features/text-markers/)
#### Marking full lines & line ranges
```js {1, 4, 7-8}
// Line 1 - targeted by line number
// Line 2
// Line 3
// Line 4 - targeted by line number
// Line 5
// Line 6
// Line 7 - targeted by range "7-8"
// Line 8 - targeted by range "7-8"
```
#### Selecting line marker types (mark, ins, del)
```js title="line-markers.js" del={2} ins={3-4} {6}
function demo() {
console.log('this line is marked as deleted')
// This line and the next one are marked as inserted
console.log('this is the second inserted line')
return 'this line uses the neutral default marker type'
}
```
#### Adding labels to line markers
```jsx {"1":5} del={"2":7-8} ins={"3":10-12}
// labeled-line-markers.jsx
<button
role="button"
{...props}
value={value}
className={buttonClassName}
disabled={disabled}
active={active}
>
{children &&
!active &&
(typeof children === 'string' ? <span>{children}</span> : children)}
</button>
```
#### Adding long labels on their own lines
```jsx {"1. Provide the value prop here:":5-6} del={"2. Remove the disabled and active states:":8-10} ins={"3. Add this to render the children inside the button:":12-15}
// labeled-line-markers.jsx
<button
role="button"
{...props}
value={value}
className={buttonClassName}
disabled={disabled}
active={active}
>
{children &&
!active &&
(typeof children === 'string' ? <span>{children}</span> : children)}
</button>
```
#### Using diff-like syntax
```diff
+this line will be marked as inserted
-this line will be marked as deleted
this is a regular line
```
---
```diff
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
+this is an actual diff file
-all contents will remain unmodified
no whitespace will be removed either
```
#### Combining syntax highlighting with diff-like syntax
```diff lang="js"
function thisIsJavaScript() {
// This entire block gets highlighted as JavaScript,
// and we can still add diff markers to it!
- console.log('Old code to be removed')
+ console.log('New and shiny code!')
}
```
#### Marking individual text inside lines
```js "given text"
function demo() {
// Mark any given text inside lines
return 'Multiple matches of the given text are supported';
}
```
#### Regular expressions
```ts /ye[sp]/
console.log('The words yes and yep will be marked.')
```
#### Escaping forward slashes
```sh /\/ho.*\//
echo "Test" > /home/test.txt
```
#### Selecting inline marker types (mark, ins, del)
```js "return true;" ins="inserted" del="deleted"
function demo() {
console.log('These are inserted and deleted marker types');
// The return statement uses the default marker type
return true;
}
```
### Word Wrap
[Word Wrap](https://expressive-code.com/key-features/word-wrap/)
#### Configuring word wrap per block
```js wrap
// Example with wrap
function getLongString() {
return 'This is a very long string that will most probably not fit into the available space unless the container is extremely wide'
}
```
---
```js wrap=false
// Example with wrap=false
function getLongString() {
return 'This is a very long string that will most probably not fit into the available space unless the container is extremely wide'
}
```
#### Configuring indentation of wrapped lines
```js wrap preserveIndent
// Example with preserveIndent (enabled by default)
function getLongString() {
return 'This is a very long string that will most probably not fit into the available space unless the container is extremely wide'
}
```
---
```js wrap preserveIndent=false
// Example with preserveIndent=false
function getLongString() {
return 'This is a very long string that will most probably not fit into the available space unless the container is extremely wide'
}
```
## Collapsible Sections
[Collapsible Sections](https://expressive-code.com/plugins/collapsible-sections/)
```js collapse={1-5, 12-14, 21-24}
// All this boilerplate setup code will be collapsed
import { someBoilerplateEngine } from '@example/some-boilerplate'
import { evenMoreBoilerplate } from '@example/even-more-boilerplate'
const engine = someBoilerplateEngine(evenMoreBoilerplate())
// This part of the code will be visible by default
engine.doSomething(1, 2, 3, calcFn)
function calcFn() {
// You can have multiple collapsed sections
const a = 1
const b = 2
const c = a + b
// This will remain visible
console.log(`Calculation result: ${a} + ${b} = ${c}`)
return c
}
// All this code until the end of the block will be collapsed again
engine.closeConnection()
engine.freeMemory()
engine.shutdown({ reason: 'End of example boilerplate code' })
```
## Line Numbers
[Line Numbers](https://expressive-code.com/plugins/line-numbers/)
### Displaying line numbers per block
```js showLineNumbers
// This code block will show line numbers
console.log('Greetings from line 2!')
console.log('I am on line 3')
```
---
```js showLineNumbers=false
// Line numbers are disabled for this block
console.log('Hello?')
console.log('Sorry, do you know what line I am on?')
```
### Changing the starting line number
```js showLineNumbers startLineNumber=5
console.log('Greetings from line 5!')
console.log('I am on line 6')
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@@ -0,0 +1,51 @@
---
title: Simple Guides for Fuwari
published: 2024-04-01
description: "How to use this blog template."
image: "./cover.jpeg"
tags: ["Fuwari", "Blogging", "Customization"]
category: Guides
draft: false
---
> Cover image source: [Source](https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/208fc754-890d-4adb-9753-2c963332675d/width=2048/01651-1456859105-(colour_1.5),girl,_Blue,yellow,green,cyan,purple,red,pink,_best,8k,UHD,masterpiece,male%20focus,%201boy,gloves,%20ponytail,%20long%20hair,.jpeg)
This blog template is built with [Astro](https://astro.build/). For the things that are not mentioned in this guide, you may find the answers in the [Astro Docs](https://docs.astro.build/).
## Front-matter of Posts
```yaml
---
title: My First Blog Post
published: 2023-09-09
description: This is the first post of my new Astro blog.
image: ./cover.jpg
tags: [Foo, Bar]
category: Front-end
draft: false
---
```
| Attribute | Description |
|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `title` | The title of the post. |
| `published` | The date the post was published. |
| `description` | A short description of the post. Displayed on index page. |
| `image` | The cover image path of the post.<br/>1. Start with `http://` or `https://`: Use web image<br/>2. Start with `/`: For image in `public` dir<br/>3. With none of the prefixes: Relative to the markdown file |
| `tags` | The tags of the post. |
| `category` | The category of the post. |
| `draft` | If this post is still a draft, which won't be displayed. |
## Where to Place the Post Files
Your post files should be placed in `src/content/posts/` directory. You can also create sub-directories to better organize your posts and assets.
```
src/content/posts/
├── post-1.md
└── post-2/
├── cover.png
└── index.md
```

View File

@@ -0,0 +1,84 @@
---
title: Markdown Extended Features
published: 2024-05-01
updated: 2024-11-29
description: 'Read more about Markdown features in Fuwari'
image: ''
tags: [Demo, Example, Markdown, Fuwari]
category: 'Examples'
draft: false
---
## GitHub Repository Cards
You can add dynamic cards that link to GitHub repositories, on page load, the repository information is pulled from the GitHub API.
::github{repo="Fabrizz/MMM-OnSpotify"}
Create a GitHub repository card with the code `::github{repo="<owner>/<repo>"}`.
```markdown
::github{repo="saicaca/fuwari"}
```
## Admonitions
Following types of admonitions are supported: `note` `tip` `important` `warning` `caution`
:::note
Highlights information that users should take into account, even when skimming.
:::
:::tip
Optional information to help a user be more successful.
:::
:::important
Crucial information necessary for users to succeed.
:::
:::warning
Critical content demanding immediate user attention due to potential risks.
:::
:::caution
Negative potential consequences of an action.
:::
### Basic Syntax
```markdown
:::note
Highlights information that users should take into account, even when skimming.
:::
:::tip
Optional information to help a user be more successful.
:::
```
### Custom Titles
The title of the admonition can be customized.
:::note[MY CUSTOM TITLE]
This is a note with a custom title.
:::
```markdown
:::note[MY CUSTOM TITLE]
This is a note with a custom title.
:::
```
### GitHub Syntax
> [!TIP]
> [The GitHub syntax](https://github.com/orgs/community/discussions/16925) is also supported.
```
> [!NOTE]
> The GitHub syntax is also supported.
> [!TIP]
> The GitHub syntax is also supported.
```

View File

@@ -0,0 +1,175 @@
---
title: Markdown Example
published: 2023-10-01
description: A simple example of a Markdown blog post.
tags: [Markdown, Blogging, Demo]
category: Examples
draft: false
---
# An h1 header
Paragraphs are separated by a blank line.
2nd paragraph. _Italic_, **bold**, and `monospace`. Itemized lists
look like:
- this one
- that one
- the other one
Note that --- not considering the asterisk --- the actual text
content starts at 4-columns in.
> Block quotes are
> written like so.
>
> They can span multiple paragraphs,
> if you like.
Use 3 dashes for an em-dash. Use 2 dashes for ranges (ex., "it's all
in chapters 12--14"). Three dots ... will be converted to an ellipsis.
Unicode is supported. ☺
## An h2 header
Here's a numbered list:
1. first item
2. second item
3. third item
Note again how the actual text starts at 4 columns in (4 characters
from the left side). Here's a code sample:
# Let me re-iterate ...
for i in 1 .. 10 { do-something(i) }
As you probably guessed, indented 4 spaces. By the way, instead of
indenting the block, you can use delimited blocks, if you like:
```
define foobar() {
print "Welcome to flavor country!";
}
```
(which makes copying & pasting easier). You can optionally mark the
delimited block for Pandoc to syntax highlight it:
```python
import time
# Quick, count to ten!
for i in range(10):
# (but not *too* quick)
time.sleep(0.5)
print i
```
### An h3 header
Now a nested list:
1. First, get these ingredients:
- carrots
- celery
- lentils
2. Boil some water.
3. Dump everything in the pot and follow
this algorithm:
find wooden spoon
uncover pot
stir
cover pot
balance wooden spoon precariously on pot handle
wait 10 minutes
goto first step (or shut off burner when done)
Do not bump wooden spoon or it will fall.
Notice again how text always lines up on 4-space indents (including
that last line which continues item 3 above).
Here's a link to [a website](http://foo.bar), to a [local
doc](local-doc.html), and to a [section heading in the current
doc](#an-h2-header). Here's a footnote [^1].
[^1]: Footnote text goes here.
Tables can look like this:
size material color
---
9 leather brown
10 hemp canvas natural
11 glass transparent
Table: Shoes, their sizes, and what they're made of
(The above is the caption for the table.) Pandoc also supports
multi-line tables:
---
keyword text
---
red Sunsets, apples, and
other red or reddish
things.
green Leaves, grass, frogs
and other things it's
not easy being.
---
A horizontal rule follows.
---
Here's a definition list:
apples
: Good for making applesauce.
oranges
: Citrus!
tomatoes
: There's no "e" in tomatoe.
Again, text is indented 4 spaces. (Put a blank line between each
term/definition pair to spread things out more.)
Here's a "line block":
| Line one
| Line too
| Line tree
and images can be specified like so:
[//]: # (![example image]&#40;./demo-banner.png "An exemplary image"&#41;)
Inline math equations go in like so: $\omega = d\phi / dt$. Display
math should get its own line and be put in in double-dollarsigns:
$$I = \int \rho R^{2} dV$$
$$
\begin{equation*}
\pi
=3.1415926535
\;8979323846\;2643383279\;5028841971\;6939937510\;5820974944
\;5923078164\;0628620899\;8628034825\;3421170679\;\ldots
\end{equation*}
$$
And note that you can backslash-escape any punctuation characters
which you wish to be displayed literally, ex.: \`foo\`, \*bar\*, etc.

View File

@@ -0,0 +1,28 @@
---
title: Include Video in the Posts
published: 2023-08-01
description: This post demonstrates how to include embedded video in a blog post.
tags: [Example, Video]
category: Examples
draft: false
---
Just copy the embed code from YouTube or other platforms, and paste it in the markdown file.
```yaml
---
title: Include Video in the Post
published: 2023-10-19
// ...
---
<iframe width="100%" height="468" src="https://www.youtube.com/embed/5gIf0_xpFPI?si=N1WTorLKL0uwLsU_" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
```
## YouTube
<iframe width="100%" height="468" src="https://www.youtube.com/embed/5gIf0_xpFPI?si=N1WTorLKL0uwLsU_" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
## Bilibili
<iframe width="100%" height="468" src="//player.bilibili.com/player.html?bvid=BV1fK4y1s7Qf&p=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"> </iframe>

View File

@@ -0,0 +1,9 @@
# About
This is the demo site for [Fuwari](https://github.com/saicaca/fuwari).
::github{repo="saicaca/fuwari"}
> ### Sources of images used in this site
> - [Unsplash](https://unsplash.com/)
> - [星と少女](https://www.pixiv.net/artworks/108916539) by [Stella](https://www.pixiv.net/users/93273965)
> - [Rabbit - v1.4 Showcase](https://civitai.com/posts/586908) by [Rabbit_YourMajesty](https://civitai.com/user/Rabbit_YourMajesty)

2
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="../.astro/types.d.ts" />

41
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,41 @@
import type { AstroIntegration } from "@swup/astro";
declare global {
interface Window {
// type from '@swup/astro' is incorrect
swup: AstroIntegration;
pagefind: {
search: (query: string) => Promise<{
results: Array<{
data: () => Promise<SearchResult>;
}>;
}>;
};
}
}
interface SearchResult {
url: string;
meta: {
title: string;
};
excerpt: string;
content?: string;
word_count?: number;
filters?: Record<string, unknown>;
anchors?: Array<{
element: string;
id: string;
text: string;
location: number;
}>;
weighted_locations?: Array<{
weight: number;
balanced_score: number;
location: number;
}>;
locations?: number[];
raw_content?: string;
raw_url?: string;
sub_results?: SearchResult[];
}

37
src/i18n/i18nKey.ts Normal file
View File

@@ -0,0 +1,37 @@
enum I18nKey {
home = "home",
about = "about",
archive = "archive",
search = "search",
tags = "tags",
categories = "categories",
recentPosts = "recentPosts",
comments = "comments",
untitled = "untitled",
uncategorized = "uncategorized",
noTags = "noTags",
wordCount = "wordCount",
wordsCount = "wordsCount",
minuteCount = "minuteCount",
minutesCount = "minutesCount",
postCount = "postCount",
postsCount = "postsCount",
themeColor = "themeColor",
lightMode = "lightMode",
darkMode = "darkMode",
systemMode = "systemMode",
more = "more",
author = "author",
publishedAt = "publishedAt",
license = "license",
}
export default I18nKey;

38
src/i18n/languages/en.ts Normal file
View File

@@ -0,0 +1,38 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const en: Translation = {
[Key.home]: "Home",
[Key.about]: "About",
[Key.archive]: "Archive",
[Key.search]: "Search",
[Key.tags]: "Tags",
[Key.categories]: "Categories",
[Key.recentPosts]: "Recent Posts",
[Key.comments]: "Comments",
[Key.untitled]: "Untitled",
[Key.uncategorized]: "Uncategorized",
[Key.noTags]: "No Tags",
[Key.wordCount]: "word",
[Key.wordsCount]: "words",
[Key.minuteCount]: "minute",
[Key.minutesCount]: "minutes",
[Key.postCount]: "post",
[Key.postsCount]: "posts",
[Key.themeColor]: "Theme Color",
[Key.lightMode]: "Light",
[Key.darkMode]: "Dark",
[Key.systemMode]: "System",
[Key.more]: "More",
[Key.author]: "Author",
[Key.publishedAt]: "Published at",
[Key.license]: "License",
};

38
src/i18n/languages/es.ts Normal file
View File

@@ -0,0 +1,38 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const es: Translation = {
[Key.home]: "Inicio",
[Key.about]: "Sobre mí",
[Key.archive]: "Archivo",
[Key.search]: "Buscar",
[Key.tags]: "Etiquetas",
[Key.categories]: "Categorías",
[Key.recentPosts]: "Publicaciones recientes",
[Key.comments]: "Comentarios",
[Key.untitled]: "Sin título",
[Key.uncategorized]: "Sin categoría",
[Key.noTags]: "Sin etiquetas",
[Key.wordCount]: "palabra",
[Key.wordsCount]: "palabras",
[Key.minuteCount]: "minuto",
[Key.minutesCount]: "minutos",
[Key.postCount]: "publicación",
[Key.postsCount]: "publicaciones",
[Key.themeColor]: "Color del tema",
[Key.lightMode]: "Claro",
[Key.darkMode]: "Oscuro",
[Key.systemMode]: "Sistema",
[Key.more]: "Más",
[Key.author]: "Autor",
[Key.publishedAt]: "Publicado el",
[Key.license]: "Licencia",
};

38
src/i18n/languages/ja.ts Normal file
View File

@@ -0,0 +1,38 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const ja: Translation = {
[Key.home]: "Home",
[Key.about]: "About",
[Key.archive]: "Archive",
[Key.search]: "検索",
[Key.tags]: "タグ",
[Key.categories]: "カテゴリ",
[Key.recentPosts]: "最近の投稿",
[Key.comments]: "コメント",
[Key.untitled]: "タイトルなし",
[Key.uncategorized]: "カテゴリなし",
[Key.noTags]: "タグなし",
[Key.wordCount]: "文字",
[Key.wordsCount]: "文字",
[Key.minuteCount]: "分",
[Key.minutesCount]: "分",
[Key.postCount]: "件の投稿",
[Key.postsCount]: "件の投稿",
[Key.themeColor]: "テーマカラー",
[Key.lightMode]: "ライト",
[Key.darkMode]: "ダーク",
[Key.systemMode]: "システム",
[Key.more]: "もっと",
[Key.author]: "作者",
[Key.publishedAt]: "公開日",
[Key.license]: "ライセンス",
};

38
src/i18n/languages/ko.ts Normal file
View File

@@ -0,0 +1,38 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const ko: Translation = {
[Key.home]: "홈",
[Key.about]: "소개",
[Key.archive]: "아카이브",
[Key.search]: "검색",
[Key.tags]: "태그",
[Key.categories]: "카테고리",
[Key.recentPosts]: "최근 게시물",
[Key.comments]: "댓글",
[Key.untitled]: "제목 없음",
[Key.uncategorized]: "분류되지 않음",
[Key.noTags]: "태그 없음",
[Key.wordCount]: "단어",
[Key.wordsCount]: "단어",
[Key.minuteCount]: "분",
[Key.minutesCount]: "분",
[Key.postCount]: "게시물",
[Key.postsCount]: "게시물",
[Key.themeColor]: "테마 색상",
[Key.lightMode]: "밝은 모드",
[Key.darkMode]: "어두운 모드",
[Key.systemMode]: "시스템 모드",
[Key.more]: "더 보기",
[Key.author]: "저자",
[Key.publishedAt]: "게시일",
[Key.license]: "라이선스",
};

38
src/i18n/languages/th.ts Normal file
View File

@@ -0,0 +1,38 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const th: Translation = {
[Key.home]: "หน้าแรก",
[Key.about]: "เกี่ยวกับ",
[Key.archive]: "คลัง",
[Key.search]: "ค้นหา",
[Key.tags]: "ป้ายกำกับ",
[Key.categories]: "หมวดหมู่",
[Key.recentPosts]: "โพสต์ล่าสุด",
[Key.comments]: "ความคิดเห็น",
[Key.untitled]: "ไม่ได้ตั้งชื่อ",
[Key.uncategorized]: "ไม่ได้จัดหมวดหมู่",
[Key.noTags]: "ไม่มีป้ายกำกับ",
[Key.wordCount]: "คำ",
[Key.wordsCount]: "คำ",
[Key.minuteCount]: "นาที",
[Key.minutesCount]: "นาที",
[Key.postCount]: "โพสต์",
[Key.postsCount]: "โพสต์",
[Key.themeColor]: "สีของธีม",
[Key.lightMode]: "สว่าง",
[Key.darkMode]: "มืด",
[Key.systemMode]: "ตามระบบ",
[Key.more]: "ดูเพิ่ม",
[Key.author]: "ผู้เขียน",
[Key.publishedAt]: "เผยแพร่เมื่อ",
[Key.license]: "สัญญาอนุญาต",
};

View File

@@ -0,0 +1,38 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const zh_CN: Translation = {
[Key.home]: "主页",
[Key.about]: "关于",
[Key.archive]: "归档",
[Key.search]: "搜索",
[Key.tags]: "标签",
[Key.categories]: "分类",
[Key.recentPosts]: "最新文章",
[Key.comments]: "评论",
[Key.untitled]: "无标题",
[Key.uncategorized]: "未分类",
[Key.noTags]: "无标签",
[Key.wordCount]: "字",
[Key.wordsCount]: "字",
[Key.minuteCount]: "分钟",
[Key.minutesCount]: "分钟",
[Key.postCount]: "篇文章",
[Key.postsCount]: "篇文章",
[Key.themeColor]: "主题色",
[Key.lightMode]: "亮色",
[Key.darkMode]: "暗色",
[Key.systemMode]: "跟随系统",
[Key.more]: "更多",
[Key.author]: "作者",
[Key.publishedAt]: "发布于",
[Key.license]: "许可协议",
};

View File

@@ -0,0 +1,38 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const zh_TW: Translation = {
[Key.home]: "首頁",
[Key.about]: "關於",
[Key.archive]: "彙整",
[Key.search]: "搜尋",
[Key.tags]: "標籤",
[Key.categories]: "分類",
[Key.recentPosts]: "最新文章",
[Key.comments]: "評論",
[Key.untitled]: "無標題",
[Key.uncategorized]: "未分類",
[Key.noTags]: "無標籤",
[Key.wordCount]: "字",
[Key.wordsCount]: "字",
[Key.minuteCount]: "分鐘",
[Key.minutesCount]: "分鐘",
[Key.postCount]: "篇文章",
[Key.postsCount]: "篇文章",
[Key.themeColor]: "主題色",
[Key.lightMode]: "亮色",
[Key.darkMode]: "暗色",
[Key.systemMode]: "跟隨系統",
[Key.more]: "更多",
[Key.author]: "作者",
[Key.publishedAt]: "發佈於",
[Key.license]: "許可協議",
};

40
src/i18n/translation.ts Normal file
View File

@@ -0,0 +1,40 @@
import { siteConfig } from "../config";
import type I18nKey from "./i18nKey";
import { en } from "./languages/en";
import { es } from "./languages/es";
import { ja } from "./languages/ja";
import { ko } from "./languages/ko";
import { th } from "./languages/th";
import { zh_CN } from "./languages/zh_CN";
import { zh_TW } from "./languages/zh_TW";
export type Translation = {
[K in I18nKey]: string;
};
const defaultTranslation = en;
const map: { [key: string]: Translation } = {
es: es,
en: en,
en_us: en,
en_gb: en,
en_au: en,
zh_cn: zh_CN,
zh_tw: zh_TW,
ja: ja,
ja_jp: ja,
ko: ko,
ko_kr: ko,
th: th,
th_th: th,
};
export function getTranslation(lang: string): Translation {
return map[lang.toLowerCase()] || defaultTranslation;
}
export function i18n(key: I18nKey): string {
const lang = siteConfig.lang || "en";
return getTranslation(lang)[key];
}

567
src/layouts/Layout.astro Normal file
View File

@@ -0,0 +1,567 @@
---
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import ConfigCarrier from "@components/ConfigCarrier.astro";
import { profileConfig, siteConfig } from "@/config";
import {
AUTO_MODE,
BANNER_HEIGHT,
BANNER_HEIGHT_EXTEND,
BANNER_HEIGHT_HOME,
DARK_MODE,
DEFAULT_THEME,
LIGHT_MODE,
PAGE_WIDTH,
} from "../constants/constants";
import { defaultFavicons } from "../constants/icon";
import type { Favicon } from "../types/config";
import { pathsEqual, url } from "../utils/url-utils";
import "katex/dist/katex.css";
interface Props {
title?: string;
banner?: string;
description?: string;
lang?: string;
setOGTypeArticle?: boolean;
}
let { title, banner, description, lang, setOGTypeArticle } = Astro.props;
// apply a class to the body element to decide the height of the banner, only used for initial page load
// Swup can update the body for each page visit, but it's after the page transition, causing a delay for banner height change
// so use Swup hooks instead to change the height immediately when a link is clicked
const isHomePage = pathsEqual(Astro.url.pathname, url("/"));
// defines global css variables
// why doing this in Layout instead of GlobalStyles: https://github.com/withastro/astro/issues/6728#issuecomment-1502203757
const configHue = siteConfig.themeColor.hue;
if (!banner || typeof banner !== "string" || banner.trim() === "") {
banner = siteConfig.banner.src;
}
// TODO don't use post cover as banner for now
banner = siteConfig.banner.src;
const enableBanner = siteConfig.banner.enable;
let pageTitle: string;
if (title) {
pageTitle = `${title} - ${siteConfig.title}`;
} else {
pageTitle = `${siteConfig.title} - ${siteConfig.subtitle}`;
}
const favicons: Favicon[] =
siteConfig.favicon.length > 0 ? siteConfig.favicon : defaultFavicons;
// const siteLang = siteConfig.lang.replace('_', '-')
if (!lang) {
lang = `${siteConfig.lang}`;
}
const siteLang = lang.replace("_", "-");
const bannerOffsetByPosition = {
top: `${BANNER_HEIGHT_EXTEND}vh`,
center: `${BANNER_HEIGHT_EXTEND / 2}vh`,
bottom: "0",
};
const bannerOffset =
bannerOffsetByPosition[siteConfig.banner.position || "center"];
---
<!DOCTYPE html>
<html lang={siteLang} class="bg-[var(--page-bg)] transition text-[14px] md:text-[16px]"
data-overlayscrollbars-initialize
>
<head>
<title>{pageTitle}</title>
<meta charset="UTF-8" />
<meta name="description" content={description || pageTitle}>
<meta name="author" content={profileConfig.name}>
<meta property="og:site_name" content={siteConfig.title}>
<meta property="og:url" content={Astro.url}>
<meta property="og:title" content={pageTitle}>
<meta property="og:description" content={description || pageTitle}>
{setOGTypeArticle ? (
<meta property="og:type" content="article" />
) : (
<meta property="og:type" content="website" />
)}
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:url" content={Astro.url}>
<meta name="twitter:title" content={pageTitle}>
<meta name="twitter:description" content={description || pageTitle}>
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
{favicons.map(favicon => (
<link rel="icon"
href={favicon.src.startsWith('/') ? url(favicon.src) : favicon.src}
sizes={favicon.sizes}
media={favicon.theme && `(prefers-color-scheme: ${favicon.theme})`}
/>
))}
<!-- Set the theme before the page is rendered to avoid a flash -->
<script is:inline define:vars={{DEFAULT_THEME, LIGHT_MODE, DARK_MODE, AUTO_MODE, BANNER_HEIGHT_EXTEND, PAGE_WIDTH, configHue}}>
// Load the theme from local storage
const theme = localStorage.getItem('theme') || DEFAULT_THEME;
switch (theme) {
case LIGHT_MODE:
document.documentElement.classList.remove('dark');
break
case DARK_MODE:
document.documentElement.classList.add('dark');
break
case AUTO_MODE:
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
// Load the hue from local storage
const hue = localStorage.getItem('hue') || configHue;
document.documentElement.style.setProperty('--hue', hue);
// calculate the --banner-height-extend, which needs to be a multiple of 4 to avoid blurry text
let offset = Math.floor(window.innerHeight * (BANNER_HEIGHT_EXTEND / 100));
offset = offset - offset % 4;
document.documentElement.style.setProperty('--banner-height-extend', `${offset}px`);
</script>
<style define:vars={{
configHue,
'page-width': `${PAGE_WIDTH}rem`,
}}></style> <!-- defines global css variables. This will be applied to <html> <body> and some other elements idk why -->
<slot name="head"></slot>
<link rel="alternate" type="application/rss+xml" title={profileConfig.name} href={`${Astro.site}rss.xml`}/>
</head>
<body class=" min-h-screen transition " class:list={[{"lg:is-home": isHomePage, "enable-banner": enableBanner}]}
data-overlayscrollbars-initialize
>
<ConfigCarrier></ConfigCarrier>
<slot />
<!-- increase the page height during page transition to prevent the scrolling animation from jumping -->
<div id="page-height-extend" class="hidden h-[300vh]"></div>
</body>
</html>
<style is:global define:vars={{
bannerOffset,
'banner-height-home': `${BANNER_HEIGHT_HOME}vh`,
'banner-height': `${BANNER_HEIGHT}vh`,
}}>
@tailwind components;
@layer components {
.enable-banner.is-home #banner-wrapper {
@apply h-[var(--banner-height-home)] translate-y-[var(--banner-height-extend)]
}
.enable-banner #banner-wrapper {
@apply h-[var(--banner-height-home)]
}
.enable-banner.is-home #banner {
@apply h-[var(--banner-height-home)] translate-y-0
}
.enable-banner #banner {
@apply h-[var(--banner-height-home)] translate-y-[var(--bannerOffset)]
}
.enable-banner.is-home #main-grid {
@apply translate-y-[var(--banner-height-extend)];
}
.enable-banner #top-row {
@apply h-[calc(var(--banner-height-home)_-_4.5rem)] transition-all duration-300
}
.enable-banner.is-home #sidebar-sticky {
@apply top-[calc(1rem_-_var(--banner-height-extend))]
}
.navbar-hidden {
@apply opacity-0 -translate-y-16
}
}
</style>
<script>
import 'overlayscrollbars/overlayscrollbars.css';
import {
OverlayScrollbars,
// ScrollbarsHidingPlugin,
// SizeObserverPlugin,
// ClickScrollPlugin
} from 'overlayscrollbars';
import {getHue, getStoredTheme, setHue, setTheme} from "../utils/setting-utils";
import {pathsEqual, url} from "../utils/url-utils";
import {
BANNER_HEIGHT,
BANNER_HEIGHT_HOME,
BANNER_HEIGHT_EXTEND,
MAIN_PANEL_OVERLAPS_BANNER_HEIGHT
} from "../constants/constants";
import { siteConfig } from '../config';
/* Preload fonts */
// (async function() {
// try {
// await Promise.all([
// document.fonts.load("400 1em Roboto"),
// document.fonts.load("700 1em Roboto"),
// ]);
// document.body.classList.remove("hidden");
// } catch (error) {
// console.log("Failed to load fonts:", error);
// }
// })();
/* TODO This is a temporary solution for style flicker issue when the transition is activated */
/* issue link: https://github.com/withastro/astro/issues/8711, the solution get from here too */
/* update: fixed in Astro 3.2.4 */
/*
function disableAnimation() {
const css = document.createElement('style')
css.appendChild(
document.createTextNode(
`*{
-webkit-transition:none!important;
-moz-transition:none!important;
-o-transition:none!important;
-ms-transition:none!important;
transition:none!important
}`
)
)
document.head.appendChild(css)
return () => {
// Force restyle
;(() => window.getComputedStyle(document.body))()
// Wait for next tick before removing
setTimeout(() => {
document.head.removeChild(css)
}, 1)
}
}
*/
const bannerEnabled = !!document.getElementById('banner-wrapper')
function setClickOutsideToClose(panel: string, ignores: string[]) {
document.addEventListener("click", event => {
let panelDom = document.getElementById(panel);
let tDom = event.target;
if (!(tDom instanceof Node)) return; // Ensure the event target is an HTML Node
for (let ig of ignores) {
let ie = document.getElementById(ig)
if (ie == tDom || (ie?.contains(tDom))) {
return;
}
}
panelDom!.classList.add("float-panel-closed");
});
}
setClickOutsideToClose("display-setting", ["display-setting", "display-settings-switch"])
setClickOutsideToClose("nav-menu-panel", ["nav-menu-panel", "nav-menu-switch"])
setClickOutsideToClose("search-panel", ["search-panel", "search-bar", "search-switch"])
function loadTheme() {
const theme = getStoredTheme()
setTheme(theme)
}
function loadHue() {
setHue(getHue())
}
function initCustomScrollbar() {
const bodyElement = document.querySelector('body');
if (!bodyElement) return;
OverlayScrollbars(
// docs say that a initialization to the body element would affect native functionality like window.scrollTo
// but just leave it here for now
{
target: bodyElement,
cancel: {
nativeScrollbarsOverlaid: true, // don't initialize the overlay scrollbar if there is a native one
}
}, {
scrollbars: {
theme: 'scrollbar-base scrollbar-auto py-1',
autoHide: 'move',
autoHideDelay: 500,
autoHideSuspend: false,
},
});
const katexElements = document.querySelectorAll('.katex-display') as NodeListOf<HTMLElement>;
const katexObserverOptions = {
root: null,
rootMargin: '100px',
threshold: 0.1
};
const processKatexElement = (element: HTMLElement) => {
if (!element.parentNode) return;
if (element.hasAttribute('data-scrollbar-initialized')) return;
const container = document.createElement('div');
container.className = 'katex-display-container';
container.setAttribute('aria-label', 'scrollable container for formulas');
element.parentNode.insertBefore(container, element);
container.appendChild(element);
OverlayScrollbars(container, {
scrollbars: {
theme: 'scrollbar-base scrollbar-auto',
autoHide: 'leave',
autoHideDelay: 500,
autoHideSuspend: false
}
});
element.setAttribute('data-scrollbar-initialized', 'true');
};
const katexObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
processKatexElement(entry.target as HTMLElement);
observer.unobserve(entry.target);
}
});
}, katexObserverOptions);
katexElements.forEach(element => {
katexObserver.observe(element);
});
}
function showBanner() {
if (!siteConfig.banner.enable) return;
const banner = document.getElementById('banner');
if (!banner) {
console.error('Banner element not found');
return;
}
banner.classList.remove('opacity-0', 'scale-105');
}
function init() {
// disableAnimation()() // TODO
loadTheme();
loadHue();
initCustomScrollbar();
showBanner();
}
/* Load settings when entering the site */
init();
const setup = () => {
// TODO: temp solution to change the height of the banner
/*
window.swup.hooks.on('animation:out:start', () => {
const path = window.location.pathname
const body = document.querySelector('body')
if (path[path.length - 1] === '/' && !body.classList.contains('is-home')) {
body.classList.add('is-home')
} else if (path[path.length - 1] !== '/' && body.classList.contains('is-home')) {
body.classList.remove('is-home')
}
})
*/
window.swup.hooks.on('link:click', () => {
// Remove the delay for the first time page load
document.documentElement.style.setProperty('--content-delay', '0ms')
// prevent elements from overlapping the navbar
if (!bannerEnabled) {
return
}
let threshold = window.innerHeight * (BANNER_HEIGHT / 100) - 72 - 16
let navbar = document.getElementById('navbar-wrapper')
if (!navbar || !document.body.classList.contains('lg:is-home')) {
return
}
if (document.body.scrollTop >= threshold || document.documentElement.scrollTop >= threshold) {
navbar.classList.add('navbar-hidden')
}
})
window.swup.hooks.on('content:replace', initCustomScrollbar)
window.swup.hooks.on('visit:start', (visit: {to: {url: string}}) => {
// change banner height immediately when a link is clicked
const bodyElement = document.querySelector('body')
if (pathsEqual(visit.to.url, url('/'))) {
bodyElement!.classList.add('lg:is-home');
} else {
bodyElement!.classList.remove('lg:is-home');
}
// increase the page height during page transition to prevent the scrolling animation from jumping
const heightExtend = document.getElementById('page-height-extend')
if (heightExtend) {
heightExtend.classList.remove('hidden')
}
// Hide the TOC while scrolling back to top
let toc = document.getElementById('toc-wrapper');
if (toc) {
toc.classList.add('toc-not-ready')
}
});
window.swup.hooks.on('page:view', () => {
// hide the temp high element when the transition is done
const heightExtend = document.getElementById('page-height-extend')
if (heightExtend) {
heightExtend.classList.remove('hidden')
}
});
window.swup.hooks.on('visit:end', (_visit: {to: {url: string}}) => {
setTimeout(() => {
const heightExtend = document.getElementById('page-height-extend')
if (heightExtend) {
heightExtend.classList.add('hidden')
}
// Just make the transition looks better
const toc = document.getElementById('toc-wrapper');
if (toc) {
toc.classList.remove('toc-not-ready')
}
}, 200)
});
}
if (window?.swup?.hooks) {
setup()
} else {
document.addEventListener('swup:enable', setup)
}
let backToTopBtn = document.getElementById('back-to-top-btn');
let toc = document.getElementById('toc-wrapper');
let navbar = document.getElementById('navbar-wrapper')
function scrollFunction() {
let bannerHeight = window.innerHeight * (BANNER_HEIGHT / 100)
if (backToTopBtn) {
if (document.body.scrollTop > bannerHeight || document.documentElement.scrollTop > bannerHeight) {
backToTopBtn.classList.remove('hide')
} else {
backToTopBtn.classList.add('hide')
}
}
if (bannerEnabled && toc) {
if (document.body.scrollTop > bannerHeight || document.documentElement.scrollTop > bannerHeight) {
toc.classList.remove('toc-hide')
} else {
toc.classList.add('toc-hide')
}
}
if (!bannerEnabled) return
if (navbar) {
const NAVBAR_HEIGHT = 72
const MAIN_PANEL_EXCESS_HEIGHT = MAIN_PANEL_OVERLAPS_BANNER_HEIGHT * 16 // The height the main panel overlaps the banner
let bannerHeight = BANNER_HEIGHT
if (document.body.classList.contains('lg:is-home') && window.innerWidth >= 1024) {
bannerHeight = BANNER_HEIGHT_HOME
}
let threshold = window.innerHeight * (bannerHeight / 100) - NAVBAR_HEIGHT - MAIN_PANEL_EXCESS_HEIGHT - 16
if (document.body.scrollTop >= threshold || document.documentElement.scrollTop >= threshold) {
navbar.classList.add('navbar-hidden')
} else {
navbar.classList.remove('navbar-hidden')
}
}
}
window.onscroll = scrollFunction
window.onresize = () => {
// calculate the --banner-height-extend, which needs to be a multiple of 4 to avoid blurry text
let offset = Math.floor(window.innerHeight * (BANNER_HEIGHT_EXTEND / 100));
offset = offset - offset % 4;
document.documentElement.style.setProperty('--banner-height-extend', `${offset}px`);
}
</script>
<script>
import PhotoSwipeLightbox from "photoswipe/lightbox"
import "photoswipe/style.css"
let lightbox: PhotoSwipeLightbox
let pswp = import("photoswipe")
function createPhotoSwipe() {
lightbox = new PhotoSwipeLightbox({
gallery: ".custom-md img, #post-cover img",
pswpModule: () => pswp,
closeSVG: '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#ffffff"><path d="M480-424 284-228q-11 11-28 11t-28-11q-11-11-11-28t11-28l196-196-196-196q-11-11-11-28t11-28q11-11 28-11t28 11l196 196 196-196q11-11 28-11t28 11q11 11 11 28t-11 28L536-480l196 196q11 11 11 28t-11 28q-11 11-28 11t-28-11L480-424Z"/></svg>',
zoomSVG: '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#ffffff"><path d="M340-540h-40q-17 0-28.5-11.5T260-580q0-17 11.5-28.5T300-620h40v-40q0-17 11.5-28.5T380-700q17 0 28.5 11.5T420-660v40h40q17 0 28.5 11.5T500-580q0 17-11.5 28.5T460-540h-40v40q0 17-11.5 28.5T380-460q-17 0-28.5-11.5T340-500v-40Zm40 220q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l224 224q11 11 11 28t-11 28q-11 11-28 11t-28-11L532-372q-30 24-69 38t-83 14Zm0-80q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>',
padding: { top: 20, bottom: 20, left: 20, right: 20 },
wheelToZoom: true,
arrowPrev: false,
arrowNext: false,
imageClickAction: 'close',
tapAction: 'close',
doubleTapAction: 'zoom',
})
lightbox.addFilter("domItemData", (itemData, element) => {
if (element instanceof HTMLImageElement) {
itemData.src = element.src
itemData.w = Number(element.naturalWidth || window.innerWidth)
itemData.h = Number(element.naturalHeight || window.innerHeight)
itemData.msrc = element.src
}
return itemData
})
lightbox.init()
}
const setup = () => {
if (!lightbox) {
createPhotoSwipe()
}
window.swup.hooks.on("page:view", () => {
createPhotoSwipe()
})
window.swup.hooks.on(
"content:replace",
() => {
lightbox?.destroy?.()
},
{ before: true },
)
}
if (window.swup) {
setup()
} else {
document.addEventListener("swup:enable", setup)
}
</script>

View File

@@ -0,0 +1,125 @@
---
import BackToTop from "@components/control/BackToTop.astro";
import Footer from "@components/Footer.astro";
import Navbar from "@components/Navbar.astro";
import SideBar from "@components/widget/SideBar.astro";
import type { MarkdownHeading } from "astro";
import { Icon } from "astro-icon/components";
import ImageWrapper from "../components/misc/ImageWrapper.astro";
import TOC from "../components/widget/TOC.astro";
import { siteConfig } from "../config";
import {
BANNER_HEIGHT,
BANNER_HEIGHT_EXTEND,
MAIN_PANEL_OVERLAPS_BANNER_HEIGHT,
} from "../constants/constants";
import Layout from "./Layout.astro";
interface Props {
title?: string;
banner?: string;
description?: string;
lang?: string;
setOGTypeArticle?: boolean;
headings?: MarkdownHeading[];
}
const {
title,
banner,
description,
lang,
setOGTypeArticle,
headings = [],
} = Astro.props;
const hasBannerCredit =
siteConfig.banner.enable && siteConfig.banner.credit.enable;
const hasBannerLink = !!siteConfig.banner.credit.url;
const mainPanelTop = siteConfig.banner.enable
? `calc(${BANNER_HEIGHT}vh - ${MAIN_PANEL_OVERLAPS_BANNER_HEIGHT}rem)`
: "5.5rem";
---
<Layout title={title} banner={banner} description={description} lang={lang} setOGTypeArticle={setOGTypeArticle}>
<!-- Navbar -->
<slot slot="head" name="head"></slot>
<div id="top-row" class="z-50 pointer-events-none relative transition-all duration-700 max-w-[var(--page-width)] px-0 md:px-4 mx-auto" class:list={[""]}>
<div id="navbar-wrapper" class="pointer-events-auto sticky top-0 transition-all">
<Navbar></Navbar>
</div>
</div>
<!-- Banner -->
{siteConfig.banner.enable && <div id="banner-wrapper" class={`absolute z-10 w-full transition duration-700 overflow-hidden`} style={`top: -${BANNER_HEIGHT_EXTEND}vh`}>
<ImageWrapper id="banner" alt="Banner image of the blog" class:list={["object-cover h-full transition duration-700 opacity-0 scale-105"]}
src={siteConfig.banner.src} position={siteConfig.banner.position}
>
</ImageWrapper>
</div>}
<!-- Main content -->
<div class="absolute w-full z-30 pointer-events-none" style={`top: ${mainPanelTop}`}>
<!-- The pointer-events-none here prevent blocking the click event of the TOC -->
<div class="relative max-w-[var(--page-width)] mx-auto pointer-events-auto">
<div id="main-grid" class="transition duration-700 w-full left-0 right-0 grid grid-cols-[17.5rem_auto] grid-rows-[auto_1fr_auto] lg:grid-rows-[auto]
mx-auto gap-4 px-0 md:px-4"
>
<!-- Banner image credit -->
{hasBannerCredit && <a href={siteConfig.banner.credit.url} id="banner-credit" target="_blank" rel="noopener" aria-label="Visit image source"
class:list={["group onload-animation transition-all absolute flex justify-center items-center rounded-full " +
"px-3 right-4 -top-[3.25rem] bg-black/60 hover:bg-black/70 h-9", {"hover:pr-9 active:bg-black/80": hasBannerLink}]}
>
<Icon class="text-white/75 text-[1.25rem] mr-1" name="material-symbols:copyright-outline-rounded" ></Icon>
<div class="text-white/75 text-xs">{siteConfig.banner.credit.text}</div>
<Icon class:list={["transition absolute text-[oklch(0.75_0.14_var(--hue))] right-4 text-[0.75rem] opacity-0",
{"group-hover:opacity-100": hasBannerLink}]}
name="fa6-solid:arrow-up-right-from-square">
</Icon>
</a>}
<SideBar class="mb-4 row-start-2 row-end-3 col-span-2 lg:row-start-1 lg:row-end-2 lg:col-span-1 lg:max-w-[17.5rem] onload-animation" headings={headings}></SideBar>
<main id="swup-container" class="transition-swup-fade col-span-2 lg:col-span-1 overflow-hidden">
<div id="content-wrapper" class="onload-animation">
<!-- the overflow-hidden here prevent long text break the layout-->
<!-- make id different from windows.swup global property -->
<slot></slot>
<div class="footer col-span-2 onload-animation hidden lg:block">
<Footer></Footer>
</div>
</div>
</main>
<div class="footer col-span-2 onload-animation block lg:hidden">
<Footer></Footer>
</div>
</div>
<BackToTop></BackToTop>
</div>
</div>
<!-- The things that should be under the banner, only the TOC for now -->
<div class="absolute w-full z-0 hidden 2xl:block">
<div class="relative max-w-[var(--page-width)] mx-auto">
<!-- TOC component -->
{siteConfig.toc.enable && <div id="toc-wrapper" class:list={["hidden lg:block transition absolute top-0 -right-[var(--toc-width)] w-[var(--toc-width)] items-center",
{"toc-hide": siteConfig.banner.enable}]}
>
<div id="toc-inner-wrapper" class="fixed top-14 w-[var(--toc-width)] h-[calc(100vh_-_20rem)] overflow-y-scroll overflow-x-hidden hide-scrollbar">
<div id="toc" class="w-full h-full transition-swup-fade ">
<div class="h-8 w-full"></div>
<TOC headings={headings}></TOC>
<div class="h-8 w-full"></div>
</div>
</div>
</div>}
<!-- #toc needs to exist for Swup to work normally -->
{!siteConfig.toc.enable && <div id="toc"></div>}
</div>
</div>
</Layout>

23
src/pages/[...page].astro Normal file
View File

@@ -0,0 +1,23 @@
---
import type { GetStaticPaths } from "astro";
import Pagination from "../components/control/Pagination.astro";
import PostPage from "../components/PostPage.astro";
import { PAGE_SIZE } from "../constants/constants";
import MainGridLayout from "../layouts/MainGridLayout.astro";
import { getSortedPosts } from "../utils/content-utils";
export const getStaticPaths = (async ({ paginate }) => {
const allBlogPosts = await getSortedPosts();
return paginate(allBlogPosts, { pageSize: PAGE_SIZE });
}) satisfies GetStaticPaths;
// https://github.com/withastro/astro/issues/6507#issuecomment-1489916992
const { page } = Astro.props;
const len = page.data.length;
---
<MainGridLayout>
<PostPage page={page}></PostPage>
<Pagination class="mx-auto onload-animation" page={page} style={`animation-delay: calc(var(--content-delay) + ${(len)*50}ms)`}></Pagination>
</MainGridLayout>

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

@@ -0,0 +1,25 @@
---
import { getEntry, render } from "astro:content";
import Markdown from "@components/misc/Markdown.astro";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import MainGridLayout from "../layouts/MainGridLayout.astro";
const aboutPost = await getEntry("spec", "about");
if (!aboutPost) {
throw new Error("About page content not found");
}
const { Content } = await render(aboutPost);
---
<MainGridLayout title={i18n(I18nKey.about)} description={i18n(I18nKey.about)}>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full ">
<Markdown class="mt-2">
<Content />
</Markdown>
</div>
</div>
</MainGridLayout>

14
src/pages/archive.astro Normal file
View File

@@ -0,0 +1,14 @@
---
import ArchivePanel from "@components/ArchivePanel.svelte";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPostsList } from "../utils/content-utils";
const sortedPostsList = await getSortedPostsList();
---
<MainGridLayout title={i18n(I18nKey.archive)}>
<ArchivePanel sortedPosts={sortedPostsList} client:only="svelte"></ArchivePanel>
</MainGridLayout>

View File

@@ -0,0 +1,136 @@
---
import path from "node:path";
import License from "@components/misc/License.astro";
import Markdown from "@components/misc/Markdown.astro";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPosts } from "@utils/content-utils";
import { getDir, getPostUrlBySlug } from "@utils/url-utils";
import { Icon } from "astro-icon/components";
import { licenseConfig } from "src/config";
import ImageWrapper from "../../components/misc/ImageWrapper.astro";
import PostMetadata from "../../components/PostMeta.astro";
import { profileConfig, siteConfig } from "../../config";
import { formatDateToYYYYMMDD } from "../../utils/date-utils";
export async function getStaticPaths() {
const blogEntries = await getSortedPosts();
return blogEntries.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content, headings } = await entry.render();
const { remarkPluginFrontmatter } = await entry.render();
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: entry.data.title,
description: entry.data.description || entry.data.title,
keywords: entry.data.tags,
author: {
"@type": "Person",
name: profileConfig.name,
url: Astro.site,
},
datePublished: formatDateToYYYYMMDD(entry.data.published),
inLanguage: entry.data.lang
? entry.data.lang.replace("_", "-")
: siteConfig.lang.replace("_", "-"),
// TODO include cover image here
};
---
<MainGridLayout banner={entry.data.image} title={entry.data.title} description={entry.data.description} lang={entry.data.lang} setOGTypeArticle={true} headings={headings}>
<script is:inline slot="head" type="application/ld+json" set:html={JSON.stringify(jsonLd)}></script>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative mb-4">
<div id="post-container" class:list={["card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full ",
{}
]}>
<!-- word count and reading time -->
<div class="flex flex-row text-black/30 dark:text-white/30 gap-5 mb-3 transition onload-animation">
<div class="flex flex-row items-center">
<div class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2">
<Icon name="material-symbols:notes-rounded"></Icon>
</div>
<div class="text-sm">{remarkPluginFrontmatter.words} {" " + i18n(I18nKey.wordsCount)}</div>
</div>
<div class="flex flex-row items-center">
<div class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2">
<Icon name="material-symbols:schedule-outline-rounded"></Icon>
</div>
<div class="text-sm">
{remarkPluginFrontmatter.minutes} {" " + i18n(remarkPluginFrontmatter.minutes === 1 ? I18nKey.minuteCount : I18nKey.minutesCount)}
</div>
</div>
</div>
<!-- title -->
<div class="relative onload-animation">
<div
data-pagefind-body data-pagefind-weight="10" data-pagefind-meta="title"
class="transition w-full block font-bold mb-3
text-3xl md:text-[2.25rem]/[2.75rem]
text-black/90 dark:text-white/90
md:before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-[0.75rem] before:left-[-1.125rem]
">
{entry.data.title}
</div>
</div>
<!-- metadata -->
<div class="onload-animation">
<PostMetadata
class="mb-5"
published={entry.data.published}
updated={entry.data.updated}
tags={entry.data.tags}
category={entry.data.category}
></PostMetadata>
{!entry.data.image && <div class="border-[var(--line-divider)] border-dashed border-b-[1px] mb-5"></div>}
</div>
<!-- always show cover as long as it has one -->
{entry.data.image &&
<ImageWrapper id="post-cover" src={entry.data.image} basePath={path.join("content/posts/", getDir(entry.id))} class="mb-8 rounded-xl banner-container onload-animation"/>
}
<Markdown class="mb-6 markdown-content onload-animation">
<Content />
</Markdown>
{licenseConfig.enable && <License title={entry.data.title} slug={entry.slug} pubDate={entry.data.published} class="mb-6 rounded-xl license-container onload-animation"></License>}
</div>
</div>
<div class="flex flex-col md:flex-row justify-between mb-4 gap-4 overflow-hidden w-full">
<a href={entry.data.nextSlug ? getPostUrlBySlug(entry.data.nextSlug) : "#"}
class:list={["w-full font-bold overflow-hidden active:scale-95", {"pointer-events-none": !entry.data.nextSlug}]}>
{entry.data.nextSlug && <div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center !justify-start gap-4" >
<Icon name="material-symbols:chevron-left-rounded" class="text-[2rem] text-[var(--primary)]" />
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
{entry.data.nextTitle}
</div>
</div>}
</a>
<a href={entry.data.prevSlug ? getPostUrlBySlug(entry.data.prevSlug) : "#"}
class:list={["w-full font-bold overflow-hidden active:scale-95", {"pointer-events-none": !entry.data.prevSlug}]}>
{entry.data.prevSlug && <div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center !justify-end gap-4">
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
{entry.data.prevTitle}
</div>
<Icon name="material-symbols:chevron-right-rounded" class="text-[2rem] text-[var(--primary)]" />
</div>}
</a>
</div>
</MainGridLayout>

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

@@ -0,0 +1,16 @@
import type { APIRoute } from "astro";
const robotsTxt = `
User-agent: *
Disallow: /_astro/
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",
},
});
};

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

@@ -0,0 +1,41 @@
import rss from "@astrojs/rss";
import { getSortedPosts } from "@utils/content-utils";
import type { APIContext } from "astro";
import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
import { siteConfig } from "@/config";
const parser = new MarkdownIt();
function stripInvalidXmlChars(str: string): string {
return str.replace(
// biome-ignore lint/suspicious/noControlCharactersInRegex: https://www.w3.org/TR/xml/#charsets
/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFDD0-\uFDEF\uFFFE\uFFFF]/g,
"",
);
}
export async function GET(context: APIContext) {
const blog = await getSortedPosts();
return rss({
title: siteConfig.title,
description: siteConfig.subtitle || "No description",
site: context.site ?? "https://fuwari.vercel.app",
items: blog.map((post) => {
const content =
typeof post.body === "string" ? post.body : String(post.body || "");
const cleanedContent = stripInvalidXmlChars(content);
return {
title: post.data.title,
pubDate: post.data.published,
description: post.data.description || "",
link: `/posts/${post.slug}/`,
content: sanitizeHtml(parser.render(cleanedContent), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
}),
};
}),
customData: `<language>${siteConfig.lang}</language>`,
});
}

View File

@@ -0,0 +1,90 @@
import { definePlugin } from "@expressive-code/core";
import type { Element } from "hast";
export function pluginCustomCopyButton() {
return definePlugin({
name: "Custom Copy Button",
hooks: {
postprocessRenderedBlock: (context) => {
function traverse(node: Element) {
if (node.type === "element" && node.tagName === "pre") {
processCodeBlock(node);
return;
}
if (node.children) {
for (const child of node.children) {
if (child.type === "element") traverse(child);
}
}
}
function processCodeBlock(node: Element) {
const copyButton = {
type: "element" as const,
tagName: "button",
properties: {
className: ["copy-btn"],
"aria-label": "Copy code",
},
children: [
{
type: "element" as const,
tagName: "div",
properties: {
className: ["copy-btn-icon"],
},
children: [
{
type: "element" as const,
tagName: "svg",
properties: {
viewBox: "0 -960 960 960",
xmlns: "http://www.w3.org/2000/svg",
className: ["copy-btn-icon", "copy-icon"],
},
children: [
{
type: "element" as const,
tagName: "path",
properties: {
d: "M368.37-237.37q-34.48 0-58.74-24.26-24.26-24.26-24.26-58.74v-474.26q0-34.48 24.26-58.74 24.26-24.26 58.74-24.26h378.26q34.48 0 58.74 24.26 24.26 24.26 24.26 58.74v474.26q0 34.48-24.26 58.74-24.26 24.26-58.74 24.26H368.37Zm0-83h378.26v-474.26H368.37v474.26Zm-155 238q-34.48 0-58.74-24.26-24.26-24.26-24.26-58.74v-515.76q0-17.45 11.96-29.48 11.97-12.02 29.33-12.02t29.54 12.02q12.17 12.03 12.17 29.48v515.76h419.76q17.45 0 29.48 11.96 12.02 11.97 12.02 29.33t-12.02 29.54q-12.03 12.17-29.48 12.17H213.37Zm155-238v-474.26 474.26Z",
},
children: [],
},
],
},
{
type: "element" as const,
tagName: "svg",
properties: {
viewBox: "0 -960 960 960",
xmlns: "http://www.w3.org/2000/svg",
className: ["copy-btn-icon", "success-icon"],
},
children: [
{
type: "element" as const,
tagName: "path",
properties: {
d: "m389-377.13 294.7-294.7q12.58-12.67 29.52-12.67 16.93 0 29.61 12.67 12.67 12.68 12.67 29.53 0 16.86-12.28 29.14L419.07-288.41q-12.59 12.67-29.52 12.67-16.94 0-29.62-12.67L217.41-430.93q-12.67-12.68-12.79-29.45-.12-16.77 12.55-29.45 12.68-12.67 29.62-12.67 16.93 0 29.28 12.67L389-377.13Z",
},
children: [],
},
],
},
],
},
],
} as Element;
if (!node.children) {
node.children = [];
}
node.children.push(copyButton);
}
traverse(context.renderData.blockAst);
},
},
});
}

View File

@@ -0,0 +1,49 @@
/**
* Based on the discussion at https://github.com/expressive-code/expressive-code/issues/153#issuecomment-2282218684
*/
import { definePlugin } from "@expressive-code/core";
export function pluginLanguageBadge() {
return definePlugin({
name: "Language Badge",
// @ts-ignore
baseStyles: ({ _cssVar }) => `
[data-language]::before {
position: absolute;
z-index: 2;
right: 0.5rem;
top: 0.5rem;
padding: 0.1rem 0.5rem;
content: attr(data-language);
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
color: oklch(0.75 0.1 var(--hue));
background: oklch(0.33 0.035 var(--hue));
border-radius: 0.5rem;
pointer-events: none;
transition: opacity 0.3s;
opacity: 0;
}
.frame:not(.has-title):not(.is-terminal) {
@media (hover: none) {
& [data-language]::before {
opacity: 1;
margin-right: 3rem;
}
& [data-language]:active::before {
opacity: 0;
}
}
@media (hover: hover) {
& [data-language]::before {
opacity: 1;
}
&:hover [data-language]::before {
opacity: 0;
}
}
}
`,
});
}

View File

@@ -0,0 +1,33 @@
/// <reference types="mdast" />
import { h } from "hastscript";
/**
* Creates an admonition component.
*
* @param {Object} properties - The properties of the component.
* @param {string} [properties.title] - An optional title.
* @param {('tip'|'note'|'important'|'caution'|'warning')} type - The admonition type.
* @param {import('mdast').RootContent[]} children - The children elements of the component.
* @returns {import('mdast').Parent} The created admonition component.
*/
export function AdmonitionComponent(properties, children, type) {
if (!Array.isArray(children) || children.length === 0)
return h(
"div",
{ class: "hidden" },
'Invalid admonition directive. (Admonition directives must be of block type ":::note{name="name"} <content> :::")',
);
let label = null;
if (properties?.["has-directive-label"]) {
label = children[0]; // The first child is the label
// biome-ignore lint/style/noParameterAssign: <check later>
children = children.slice(1);
label.tagName = "div"; // Change the tag <p> to <div>
}
return h("blockquote", { class: `admonition bdm-${type}` }, [
h("span", { class: "bdm-title" }, label ? label : type.toUpperCase()),
...children,
]);
}

View File

@@ -0,0 +1,95 @@
/// <reference types="mdast" />
import { h } from "hastscript";
/**
* Creates a GitHub Card component.
*
* @param {Object} properties - The properties of the component.
* @param {string} properties.repo - The GitHub repository in the format "owner/repo".
* @param {import('mdast').RootContent[]} children - The children elements of the component.
* @returns {import('mdast').Parent} The created GitHub Card component.
*/
export function GithubCardComponent(properties, children) {
if (Array.isArray(children) && children.length !== 0)
return h("div", { class: "hidden" }, [
'Invalid directive. ("github" directive must be leaf type "::github{repo="owner/repo"}")',
]);
if (!properties.repo || !properties.repo.includes("/"))
return h(
"div",
{ class: "hidden" },
'Invalid repository. ("repo" attributte must be in the format "owner/repo")',
);
const repo = properties.repo;
const cardUuid = `GC${Math.random().toString(36).slice(-6)}`; // Collisions are not important
const nAvatar = h(`div#${cardUuid}-avatar`, { class: "gc-avatar" });
const nLanguage = h(
`span#${cardUuid}-language`,
{ class: "gc-language" },
"Waiting...",
);
const nTitle = h("div", { class: "gc-titlebar" }, [
h("div", { class: "gc-titlebar-left" }, [
h("div", { class: "gc-owner" }, [
nAvatar,
h("div", { class: "gc-user" }, repo.split("/")[0]),
]),
h("div", { class: "gc-divider" }, "/"),
h("div", { class: "gc-repo" }, repo.split("/")[1]),
]),
h("div", { class: "github-logo" }),
]);
const nDescription = h(
`div#${cardUuid}-description`,
{ class: "gc-description" },
"Waiting for api.github.com...",
);
const nStars = h(`div#${cardUuid}-stars`, { class: "gc-stars" }, "00K");
const nForks = h(`div#${cardUuid}-forks`, { class: "gc-forks" }, "0K");
const nLicense = h(`div#${cardUuid}-license`, { class: "gc-license" }, "0K");
const nScript = h(
`script#${cardUuid}-script`,
{ type: "text/javascript", defer: true },
`
fetch('https://api.github.com/repos/${repo}', { referrerPolicy: "no-referrer" }).then(response => response.json()).then(data => {
document.getElementById('${cardUuid}-description').innerText = data.description?.replace(/:[a-zA-Z0-9_]+:/g, '') || "Description not set";
document.getElementById('${cardUuid}-language').innerText = data.language;
document.getElementById('${cardUuid}-forks').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\u202f", '');
document.getElementById('${cardUuid}-stars').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\u202f", '');
const avatarEl = document.getElementById('${cardUuid}-avatar');
avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + ')';
avatarEl.style.backgroundColor = 'transparent';
document.getElementById('${cardUuid}-license').innerText = data.license?.spdx_id || "no-license";
document.getElementById('${cardUuid}-card').classList.remove("fetch-waiting");
console.log("[GITHUB-CARD] Loaded card for ${repo} | ${cardUuid}.")
}).catch(err => {
const c = document.getElementById('${cardUuid}-card');
c?.classList.add("fetch-error");
console.warn("[GITHUB-CARD] (Error) Loading card for ${repo} | ${cardUuid}.")
})
`,
);
return h(
`a#${cardUuid}-card`,
{
class: "card-github fetch-waiting no-styling",
href: `https://github.com/${repo}`,
target: "_blank",
repo,
},
[
nTitle,
nDescription,
h("div", { class: "gc-infobar" }, [nStars, nForks, nLicense, nLanguage]),
nScript,
],
);
}

View File

@@ -0,0 +1,30 @@
import { h } from "hastscript";
import { visit } from "unist-util-visit";
export function parseDirectiveNode() {
return (tree, { _data }) => {
visit(tree, (node) => {
if (
node.type === "containerDirective" ||
node.type === "leafDirective" ||
node.type === "textDirective"
) {
// biome-ignore lint/suspicious/noAssignInExpressions: <check later>
const data = node.data || (node.data = {});
node.attributes = node.attributes || {};
if (
node.children.length > 0 &&
node.children[0].data &&
node.children[0].data.directiveLabel
) {
// Add a flag to the node to indicate that it has a directive label
node.attributes["has-directive-label"] = true;
}
const hast = h(node.name, node.attributes);
data.hName = hast.tagName;
data.hProperties = hast.properties;
}
});
};
}

View File

@@ -0,0 +1,17 @@
// biome-ignore lint/suspicious/noShadowRestrictedNames: <toString from mdast-util-to-string>
import { toString } from "mdast-util-to-string";
/* Use the post's first paragraph as the excerpt */
export function remarkExcerpt() {
return (tree, { data }) => {
let excerpt = "";
for (const node of tree.children) {
if (node.type !== "paragraph") {
continue;
}
excerpt = toString(node);
break;
}
data.astro.frontmatter.excerpt = excerpt;
};
}

View File

@@ -0,0 +1,15 @@
// biome-ignore lint/suspicious/noShadowRestrictedNames: <toString from mdast-util-to-string>
import { toString } from "mdast-util-to-string";
import getReadingTime from "reading-time";
export function remarkReadingTime() {
return (tree, { data }) => {
const textOnPage = toString(tree);
const readingTime = getReadingTime(textOnPage);
data.astro.frontmatter.minutes = Math.max(
1,
Math.round(readingTime.minutes),
);
data.astro.frontmatter.words = readingTime.words;
};
}

View File

@@ -0,0 +1,3 @@
.expressive-code .frame {
@apply !shadow-none;
}

141
src/styles/main.css Normal file
View File

@@ -0,0 +1,141 @@
@tailwind components;
@layer components {
.card-base {
@apply rounded-[var(--radius-large)] overflow-hidden bg-[var(--card-bg)] transition;
}
h1, h2, h3, h4, h5, h6, p, a, span, li, ul, ol, blockquote, code, pre, table, th, td, strong {
@apply transition;
}
.card-shadow {
@apply drop-shadow-[0_2px_4px_rgba(0,0,0,0.005)]
}
.expand-animation {
@apply relative before:ease-out before:transition active:bg-none hover:before:bg-[var(--btn-plain-bg-hover)] active:before:bg-[var(--btn-plain-bg-active)] z-0
before:absolute before:rounded-[inherit] before:inset-0 before:scale-[0.85] hover:before:scale-100 before:-z-10
}
.link {
@apply transition rounded-md p-1 -m-1 expand-animation;
}
.link-lg {
@apply transition rounded-md p-1.5 -m-1.5 expand-animation;
}
.float-panel {
@apply top-[5.25rem] rounded-[var(--radius-large)] overflow-hidden bg-[var(--float-panel-bg)] transition shadow-xl dark:shadow-none
}
.float-panel-closed {
@apply -translate-y-1 opacity-0 pointer-events-none
}
.search-panel mark {
@apply bg-transparent text-[var(--primary)]
}
.btn-card {
@apply transition flex items-center justify-center bg-[var(--card-bg)] hover:bg-[var(--btn-card-bg-hover)]
active:bg-[var(--btn-card-bg-active)]
}
.btn-card.disabled {
@apply pointer-events-none text-black/10 dark:text-white/10
}
.btn-plain {
@apply transition relative flex items-center justify-center bg-none
text-black/75 hover:text-[var(--primary)] dark:text-white/75 dark:hover:text-[var(--primary)];
&:not(.scale-animation) {
@apply hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]
}
&.scale-animation {
@apply expand-animation;
&.current-theme-btn {
@apply before:scale-100 before:opacity-100 before:bg-[var(--btn-plain-bg-hover)] text-[var(--primary)]
}
}
}
.btn-regular {
@apply transition flex items-center justify-center bg-[var(--btn-regular-bg)] hover:bg-[var(--btn-regular-bg-hover)] active:bg-[var(--btn-regular-bg-active)]
text-[var(--btn-content)] dark:text-white/75
}
.link-underline {
@apply transition underline decoration-2 decoration-dashed decoration-[var(--link-underline)]
hover:decoration-[var(--link-hover)] active:decoration-[var(--link-active)] underline-offset-[0.25rem]
}
.toc-hide,
.toc-not-ready {
@apply opacity-0 pointer-events-none
}
#toc-inner-wrapper {
mask-image: linear-gradient(to bottom, transparent 0%, black 2rem, black calc(100% - 2rem), transparent 100%);
}
.hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.text-90 {
@apply text-black/90 dark:text-white/90
}
.text-75 {
@apply text-black/75 dark:text-white/75
}
.text-50 {
@apply text-black/50 dark:text-white/50
}
.text-30 {
@apply text-black/30 dark:text-white/30
}
.text-25 {
@apply text-black/25 dark:text-white/25
}
.meta-icon {
@apply w-8 h-8 transition rounded-md flex items-center justify-center bg-[var(--btn-regular-bg)]
text-[var(--btn-content)] mr-2
}
.with-divider {
@apply before:content-['/'] before:ml-1.5 before:mr-1.5 before:text-[var(--meta-divider)] before:text-sm
before:font-medium before:first-of-type:hidden before:transition
}
.btn-regular-dark {
@apply flex items-center justify-center
bg-[oklch(0.45_0.01_var(--hue))] hover:bg-[oklch(0.50_0.01_var(--hue))] active:bg-[oklch(0.55_0.01_var(--hue))]
dark:bg-[oklch(0.30_0.02_var(--hue))] dark:hover:bg-[oklch(0.35_0.03_var(--hue))] dark:active:bg-[oklch(0.40_0.03_var(--hue))]
}
.btn-regular-dark.success {
@apply bg-[oklch(0.75_0.14_var(--hue))] dark:bg-[oklch(0.75_0.14_var(--hue))]
}
}
.custom-md img, #post-cover img {
@apply cursor-zoom-in
}
::selection {
background-color: var(--selection-bg)
}
.dash-line {
position: relative;
}
.dash-line::before {
content: "";
position: absolute;
width: 10%;
height: 100%;
left: calc(50% - 1px);
border-left: 2px dashed var(--line-color);
pointer-events: none;
transition: all 0.3s;
transform: translateY(-50%);
}
.collapsed {
height: var(--collapsedHeight);
}

View File

@@ -0,0 +1,245 @@
.custom-md
blockquote.admonition
.bdm-title
display: flex
align-items: center
margin-bottom: -.9rem
font-weight: bold
&:before
content: ' '
display: inline-block
font-size: inherit
overflow: visible
margin-right: .6rem
height: 1em
width: 1em
vertical-align: -.126em
mask-size: contain
mask-position: center
mask-repeat: no-repeat
transform: translateY(-0.0625rem)
&.bdm-tip
.bdm-title
color: var(--admonitions-color-tip)
&:before
background: var(--admonitions-color-tip)
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z'%3E%3C/path%3E%3C/svg%3E")
&:before
background: var(--admonitions-color-tip)
&.bdm-note
.bdm-title
color: var(--admonitions-color-note)
&:before
background: var(--admonitions-color-note)
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath fill='var(--admonitions-color-tip)' d='M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'%3E%3C/path%3E%3C/svg%3E")
&:before
background: var(--admonitions-color-note)
&.bdm-important
.bdm-title
color: var(--admonitions-color-important)
&:before
background: var(--admonitions-color-important)
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z'%3E%3C/path%3E%3C/svg%3E")
&:before
background: var(--admonitions-color-important)
&.bdm-warning
.bdm-title
color: var(--admonitions-color-warning)
&:before
background: var(--admonitions-color-warning)
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z'%3E%3C/path%3E%3C/svg%3E")
&:before
background: var(--admonitions-color-warning)
&.bdm-caution
.bdm-title
color: var(--admonitions-color-caution)
&:before
background: var(--admonitions-color-caution)
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'%3E%3C/path%3E%3C/svg%3E")
&:before
background: var(--admonitions-color-caution)
img
border-radius: 0.75rem
hr
border-color: var(--line-divider)
border-style: dashed
iframe
border-radius: 0.75rem
margin-left: auto
margin-right: auto
max-width: 100%
a.card-github
display: block
background: var(--license-block-bg)
position: relative
margin: 0.5rem 0
padding: 1.1rem 1.5rem 1.1rem 1.5rem
color: var(--tw-prose-body)
border-radius: var(--radius-large)
text-decoration-thickness: 0px
text-decoration-line: none
&:hover
background-color: var(--btn-regular-bg-hover)
.gc-titlebar
color: var(--btn-content)
.gc-stars, .gc-forks, .gc-license, .gc-description
color: var(--tw-prose-headings)
&:before
background-color: var(--tw-prose-headings)
&:active
scale: .98
background-color: var(--btn-regular-bg-active);
.gc-titlebar
display: flex
align-items: center
justify-content: space-between
margin-bottom: 0.5rem
color: var(--tw-prose-headings)
font-size: 1.25rem
font-weight: 500
.gc-titlebar-left
display: flex
flex-flow: row nowrap
gap: 0.5rem
.gc-repo
font-weight: bold
.gc-owner
font-weight: 300
position: relative
display: flex
flex-flow: row nowrap
gap: 0.5rem
align-items: center
.gc-avatar
display: block
overflow: hidden
width: 1.5rem
height: 1.5rem
margin-top: -0.1rem
background-color: var(--primary)
background-size: cover
border-radius: 50%
.gc-description
margin-bottom: 0.7rem
font-size: 1rem
font-weight: 300
line-height: 1.5rem
color: var(--tw-prose-body)
.gc-infobar
display: flex
flex-flow: row nowrap
gap: 1.5rem
color: var(--tw-prose-body)
width: fit-content
.gc-language
display: none
.gc-stars, .gc-forks, .gc-license, .github-logo
font-weight: 500
font-size: 0.875rem
opacity: 0.9;
&:before
content: ' '
display: inline-block
height: 1.3em
width: 1.3em
margin-right: .4rem
vertical-align: -.24em
font-size: inherit
background-color: var(--tw-prose-body)
overflow: visible
mask-size: contain
mask-position: center
mask-repeat: no-repeat
transition-property: background-color, background;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1)
transition-duration: 0.15s
.gc-stars
&:before
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16'%3E%3Cpath d='M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z'%3E%3C/path%3E%3C/svg%3E")
.gc-license
&:before
margin-right: .5rem
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16'%3E%3Cpath d='M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z'%3E%3C/path%3E%3C/svg%3E")
.gc-forks
&:before
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16'%3E%3Cpath d='M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z'%3E%3C/path%3E%3C/svg%3E")
.github-logo
font-size: 1.25rem
&:before
background-color: var(--tw-prose-headings)
margin-right: 0
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='31' height='32' viewBox='0 0 496 512'%3E%3Cpath fill='%23a1f7cb' d='M165.9 397.4c0 2-2.3 3.6-5.2 3.6c-3.3.3-5.6-1.3-5.6-3.6c0-2 2.3-3.6 5.2-3.6c3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9c2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9c.3 2 2.9 3.3 5.9 2.6c2.9-.7 4.9-2.6 4.6-4.6c-.3-1.9-3-3.2-5.9-2.9M244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2c12.8 2.3 17.3-5.6 17.3-12.1c0-6.2-.3-40.4-.3-61.4c0 0-70 15-84.7-29.8c0 0-11.4-29.1-27.8-36.6c0 0-22.9-15.7 1.6-15.4c0 0 24.9 2 38.6 25.8c21.9 38.6 58.6 27.5 72.9 20.9c2.3-16 8.8-27.1 16-33.7c-55.9-6.2-112.3-14.3-112.3-110.5c0-27.5 7.6-41.3 23.6-58.9c-2.6-6.5-11.1-33.3 2.6-67.9c20.9-6.5 69 27 69 27c20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27c13.7 34.7 5.2 61.4 2.6 67.9c16 17.7 25.8 31.5 25.8 58.9c0 96.5-58.9 104.2-114.8 110.5c9.2 7.9 17 22.9 17 46.4c0 33.7-.3 75.4-.3 83.6c0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252C496 113.3 383.5 8 244.8 8M97.2 352.9c-1.3 1-1 3.3.7 5.2c1.6 1.6 3.9 2.3 5.2 1c1.3-1 1-3.3-.7-5.2c-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9c1.6 1 3.6.7 4.3-.7c.7-1.3-.3-2.9-2.3-3.9c-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2c2.3 2.3 5.2 2.6 6.5 1c1.3-1.3.7-4.3-1.3-6.2c-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9c1.6 2.3 4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2c-1.4-2.3-4-3.3-5.6-2'/%3E%3C/svg%3E")
a.card-github.fetch-waiting
pointer-events: none
opacity: 0.7
transition: opacity 0.15s ease-in-out
.gc-description, .gc-infobar, .gc-avatar
background-color: var(--tw-prose-body)
color: transparent
opacity: 0.5;
animation: pulsate 2s infinite linear
user-select: none
&:before
background-color: transparent
.gc-repo
margin-left: -0.1rem
.gc-description, .gc-infobar
border-radius: 0.5rem
a.card-github.fetch-error
pointer-events: all
opacity: 1
@keyframes pulsate
0%
opacity: 0.15
50%
opacity: 0.25
100%
opacity: 0.15
.card-github, .gc-description, .gc-titlebar, .gc-stars, .gc-forks, .gc-license, .gc-avatar, .github-logo
transition-property: all
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1)
transition-duration: 0.15s

Some files were not shown because too many files have changed in this diff Show More