init
59
.github/ISSUE_TEMPLATE/01-bug_report.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
assignees:
|
||||
- L4Ph
|
||||
- saicaca
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: to-reproduce
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: OS
|
||||
multiple: true
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
- Android
|
||||
- iOS
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
placeholder: e.g. chrome, safari
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
41
.github/ISSUE_TEMPLATE/02-feature_request.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
assignees:
|
||||
- saicaca
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this feature request!
|
||||
- type: textarea
|
||||
id: related-problem
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Disclaimer**
|
||||
|
||||
Please note that this feature request is at the discretion of the repository owner, @saicaca, and its implementation is not guaranteed.
|
||||
11
.github/ISSUE_TEMPLATE/03-custom_issue.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
name: Custom Issue
|
||||
description: Describe your issue here.
|
||||
title: "[Other]: "
|
||||
body:
|
||||
- type: textarea
|
||||
id: issue-description
|
||||
attributes:
|
||||
label: Issue Description
|
||||
description: Please describe your issue.
|
||||
validations:
|
||||
required: true
|
||||
22
.github/dependabot.yml
vendored
Normal 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"]
|
||||
37
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix (a non-breaking change that fixes an issue)
|
||||
- [ ] New feature (a non-breaking change that adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Other (please describe):
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read the [**CONTRIBUTING**](https://github.com/saicaca/fuwari/blob/main/CONTRIBUTING.md) document.
|
||||
- [ ] I have checked to ensure that this Pull Request is not for personal changes.
|
||||
- [ ] I have performed a self-review of my own code.
|
||||
- [ ] My changes generate no new warnings.
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!-- Please link to the issue that this pull request addresses. e.g. #123 -->
|
||||
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Please describe the changes you made in this pull request. -->
|
||||
|
||||
|
||||
## How To Test
|
||||
|
||||
<!-- Please describe how you tested your changes. -->
|
||||
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
<!-- If you made any UI changes, please include screenshots. -->
|
||||
|
||||
|
||||
## Additional Notes
|
||||
|
||||
<!-- Any additional information that you want to share with the reviewer. -->
|
||||
20
.github/workflows/biome.yml
vendored
Normal 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
@@ -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
|
||||
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# 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
|
||||
|
||||
# ide
|
||||
.idea
|
||||
*.iml
|
||||
.vscode
|
||||
21
CONTRIBUTING.md
Normal 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
@@ -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.
|
||||
99
README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 🍥Fuwari
|
||||

|
||||

|
||||
[](https://deepwiki.com/saicaca/fuwari)
|
||||
[](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)
|
||||
|
||||

|
||||
|
||||
🌏 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) /
|
||||
[**Tiếng Việt**](https://github.com/saicaca/fuwari/blob/main/docs/README.vi.md) /
|
||||
[**Bahasa Indonesia**](https://github.com/saicaca/fuwari/blob/main/docs/README.id.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.
|
||||
|
||||
[](https://app.fossa.com/projects/git%2Bgithub.com%2Fsaicaca%2Ffuwari?ref=badge_large&issueType=license)
|
||||
172
astro.config.mjs
Normal 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
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.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
@@ -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)
|
||||
|
||||

|
||||
|
||||
## ✨ 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 |
|
||||
106
docs/README.id.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 🍥 Fuwari
|
||||
|
||||
Template blog statis yang dibangun dengan [Astro](https://astro.build).
|
||||
|
||||
[**🖥️ Demo Langsung (Vercel)**](https://fuwari.vercel.app)
|
||||
|
||||

|
||||
|
||||
🌏 README dalam
|
||||
[**中文**](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) /
|
||||
[**Tiếng Việt**](https://github.com/saicaca/fuwari/blob/main/docs/README.vi.md) /
|
||||
**Bahasa Indonesia (ini)** (Disediakan oleh komunitas, mungkin tidak selalu paling mutakhir)
|
||||
|
||||
## ✨ Fitur
|
||||
|
||||
- [x] Dibangun dengan [Astro](https://astro.build) dan [Tailwind CSS](https://tailwindcss.com)
|
||||
- [x] Animasi dan transisi halaman yang halus
|
||||
- [x] Mode terang / gelap
|
||||
- [x] Warna tema & banner yang bisa dikustomisasi
|
||||
- [x] Desain responsif
|
||||
- [x] Fitur pencarian dengan [Pagefind](https://pagefind.app/)
|
||||
- [x] [Fitur markdown tambahan](#-markdown-sintaks-ekstensi)
|
||||
- [x] Daftar isi (Table of Contents)
|
||||
- [x] RSS feed
|
||||
|
||||
## 🚀 Memulai
|
||||
|
||||
1. Buat repositori blog kamu:
|
||||
- [Generate repositori baru](https://github.com/saicaca/fuwari/generate) dari template ini atau fork repositori ini.
|
||||
- Atau jalankan salah satu perintah berikut:
|
||||
```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
|
||||
```
|
||||
2. Untuk mengedit blog secara lokal, klon repositori kamu, jalankan `pnpm install` untuk instalasi dependensi.
|
||||
- Install [pnpm](https://pnpm.io) `npm install -g pnpm` jika belum punya.
|
||||
3. Edit file konfigurasi `src/config.ts` untuk menyesuaikan blog.
|
||||
4. Jalankan `pnpm new-post <nama-file>` untuk membuat postingan baru dan edit di `src/content/posts/`.
|
||||
5. Deploy blog ke Vercel, Netlify, GitHub Pages, dll. sesuai [panduan](https://docs.astro.build/en/guides/deploy/). Jangan lupa edit konfigurasi situs di `astro.config.mjs` sebelum deploy.
|
||||
|
||||
## 📝 Frontmatter Postingan
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Judul Postingan Pertama Saya
|
||||
published: 2023-09-09
|
||||
description: Ini adalah postingan pertama blog Astro saya.
|
||||
image: ./cover.jpg
|
||||
tags: [Foo, Bar]
|
||||
category: Front-end
|
||||
draft: false
|
||||
lang: id # Isi hanya jika bahasa postingan berbeda dari bahasa default di `config.ts`
|
||||
---
|
||||
```
|
||||
|
||||
## 🧩 Markdown Sintaks Ekstensi
|
||||
|
||||
Selain dukungan default Astro untuk [GitHub Flavored Markdown](https://github.github.com/gfm/), terdapat beberapa fitur tambahan:
|
||||
|
||||
- Admonisi ([Pratinjau & Cara Pakai](https://fuwari.vercel.app/posts/markdown-extended/#admonitions))
|
||||
- Kartu repositori GitHub ([Pratinjau & Cara Pakai](https://fuwari.vercel.app/posts/markdown-extended/#github-repository-cards))
|
||||
- Kode blok ekspresif lewat Expressive Code ([Pratinjau](https://fuwari.vercel.app/posts/expressive-code/) / [Dokumentasi](https://expressive-code.com/))
|
||||
|
||||
## ⚡ Perintah
|
||||
|
||||
Semua perintah dijalankan dari root proyek, via terminal:
|
||||
|
||||
| Perintah | Aksi |
|
||||
|:-----------------------------|:----------------------------------------------------------|
|
||||
| `pnpm install` | Instalasi dependensi |
|
||||
| `pnpm dev` | Menjalankan server dev lokal di `localhost:4321` |
|
||||
| `pnpm build` | Build untuk produksi ke folder `./dist/` |
|
||||
| `pnpm preview` | Pratinjau hasil build sebelum deploy |
|
||||
| `pnpm check` | Cek error atau masalah di kode |
|
||||
| `pnpm format` | Format kode dengan Biome |
|
||||
| `pnpm new-post <nama-file>` | Membuat postingan baru |
|
||||
| `pnpm astro ...` | Jalankan perintah CLI seperti `astro add`, `astro check` |
|
||||
| `pnpm astro --help` | Bantuan menggunakan Astro CLI |
|
||||
|
||||
## ✏️ Kontribusi
|
||||
|
||||
Lihat [Panduan Kontribusi](https://github.com/saicaca/fuwari/blob/main/CONTRIBUTING.md) untuk detail tentang cara berkontribusi ke proyek ini.
|
||||
|
||||
## 📄 Lisensi
|
||||
|
||||
Proyek ini dilisensikan di bawah MIT License.
|
||||
|
||||
---
|
||||
|
||||
> Dokumentasi ini tersedia dalam Bahasa Indonesia. Untuk bahasa lain, lihat README di direktori docs.
|
||||
85
docs/README.ja.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 🍥Fuwari
|
||||
|
||||
[Astro](https://astro.build) で構築された静的ブログテンプレート
|
||||
|
||||
[**🖥️ライブデモ (Vercel)**](https://fuwari.vercel.app)
|
||||
|
||||

|
||||
|
||||
## ✨ 特徴
|
||||
|
||||
- [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
@@ -0,0 +1,82 @@
|
||||
# 🍥Fuwari
|
||||
|
||||
[Astro](https://astro.build)로 구축된 정적 블로그 템플릿입니다.
|
||||
|
||||
[**🖥️미리보기 (Vercel)**](https://fuwari.vercel.app)
|
||||
|
||||

|
||||
|
||||
## ✨ 특징
|
||||
|
||||
- [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 라이선스에 따라 라이선스가 부여됩니다.
|
||||
|
||||
[](https://app.fossa.com/projects/git%2Bgithub.com%2Fsaicaca%2Ffuwari?ref=badge_large&issueType=license)
|
||||
84
docs/README.th.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 🍥Fuwari
|
||||
|
||||
แม่แบบสำหรับเว็บบล็อกแบบ static สร้างด้วย [Astro](https://astro.build)
|
||||
|
||||
[**🖥️ ตัวอย่างการใช้งานจริง (Vercel)**](https://fuwari.vercel.app)
|
||||
|
||||

|
||||
|
||||
## ✨ คุณสมบัติ
|
||||
|
||||
- [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
|
||||
84
docs/README.vi.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 🍥Fuwari
|
||||
|
||||
Một mẫu blog tĩnh được xây bằng [Astro](https://astro.build).
|
||||
|
||||
[**🖥️ Xem bản dùng thử (Vercel)**](https://fuwari.vercel.app)
|
||||
|
||||

|
||||
|
||||
## ✨ Tính năng
|
||||
|
||||
- [x] Được xây dựng bằng [Astro](https://astro.build) và [Tailwind CSS](https://tailwindcss.com)
|
||||
- [x] Có hoạt ảnh đổi chuyển trang mượt mà
|
||||
- [x] Chế độ sáng / tối
|
||||
- [x] Màu sắc và biểu ngữ có thể tùy chỉnh được
|
||||
- [x] Thiết kế nhanh nhạy
|
||||
- [x] Có chức năng tìm kiếm với [Pagefind](https://pagefind.app/)
|
||||
- [x] [Có các tính năng mở rộng của Markdown](https://github.com/saicaca/fuwari?tab=readme-ov-file#-markdown-extended-syntax)
|
||||
- [x] Có mục lục
|
||||
- [x] Nguồn cấp dữ liệu RSS
|
||||
|
||||
## 🚀 Bắt đầu
|
||||
|
||||
1. Tạo kho lưu trữ blog của bạn:
|
||||
- [Tạo một kho lưu trữ mới](https://github.com/saicaca/fuwari/generate) từ mẫu này hoặc fork kho lưu trữ này.
|
||||
- Hoặc chạy một trong các lệnh sau:
|
||||
```sh
|
||||
npm create fuwari@latest
|
||||
yarn create fuwari
|
||||
pnpm create fuwari@latest
|
||||
bun create fuwari@latest
|
||||
deno run -A npm:create-fuwari@latest
|
||||
```
|
||||
2. Để chỉnh sửa blog của bạn trên máy cục bộ, hãy clone kho lưu trữ của bạn, chạy lệnh `pnpm install` để cài đặt các phụ thuộc..
|
||||
- Cài đặt [pnpm](https://pnpm.io) `npm install -g pnpm` nếu chưa có.
|
||||
3. Chỉnh sửa tệp cấu hình `src/config.ts` để tùy chỉnh blog của bạn.
|
||||
4. Chạy `pnpm new-post <filename>` để tạo một bài viết mới và chỉnh sửa nó trong `src/content/posts/`.
|
||||
5. Triển khai blog của bạn lên Vercel, Netlify, GitHub Pages, etc. theo [chỉ dẫn](https://docs.astro.build/en/guides/deploy/). Bạn cần chỉnh sửa cấu hình trang web trong `astro.config.mjs` trước khi triển khai.
|
||||
|
||||
## 📝 Tiêu đề đầy đủ của bài viết
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Blog đầu tiên của mình
|
||||
published: 2023-09-09
|
||||
description: Đây là bài viết đầu tiên vủa mình trên trang blog tạo bằng Astro này.
|
||||
image: ./cover.jpg
|
||||
tags: [Foo, Bar]
|
||||
category: Front-end
|
||||
draft: false
|
||||
lang: jp # Chỉ đặt nếu ngôn ngữ của bài viết khác với ngôn ngữ của trang web trong `config.ts`
|
||||
---
|
||||
```
|
||||
|
||||
## 🧩 Cú pháp Markdown mở rộng
|
||||
|
||||
Ngoài việc Astro đã có hỗ trợ mặc định cho [Markdown vị Github](https://github.github.com/gfm/), một số tính năng Markdown khác cũng đã được bổ sung:
|
||||
|
||||
- Chêm xen ([Xem trước và Cách sử dụng](https://fuwari.vercel.app/posts/markdown-extended/#admonitions))
|
||||
- Thẻ hiển thị kho lưu trữ GitHub ([Xem trước và Cách sử dụng](https://fuwari.vercel.app/posts/markdown-extended/#github-repository-cards))
|
||||
- Các khối mã nâng cao với Expressive Code ([Xem trước](https://fuwari.vercel.app/posts/expressive-code/) / [Tài liệu](https://expressive-code.com/))
|
||||
|
||||
## ⚡ Lệnh
|
||||
|
||||
Tất cả các lệnh được chạy từ thư mục gốc của dự án, từ một bảng điều khiển:
|
||||
|
||||
| Lệnh | Mục đích |
|
||||
|:---------------------------|:----------------------------------------------------|
|
||||
| `pnpm install` | Cài đặt các phụ thuộc |
|
||||
| `pnpm dev` | Khởi động máy chủ cục bộ tại `localhost:4321` |
|
||||
| `pnpm build` | Xây dựng trang web của bạn vào `./dist/` |
|
||||
| `pnpm preview` | Xem trước bản web cục bộ của bạn, trước khi triển khai |
|
||||
| `pnpm check` | Chạy kiểm tra lỗi trong mã của bạn |
|
||||
| `pnpm format` | Định dạng mã của bạn bằng Biome |
|
||||
| `pnpm new-post <filename>` | Tạo một bài viết mới |
|
||||
| `pnpm astro ...` | Chạy các lệnh CLI như `astro add`, `astro check` |
|
||||
| `pnpm astro --help` | Nhận trợ giúp sử dụng Astro CLI |
|
||||
|
||||
## ✏️ Đóng góp
|
||||
|
||||
Xem [Hướng dẫn đóng góp](https://github.com/saicaca/fuwari/blob/main/CONTRIBUTING.md) để biết thêm chi tiết về cách đóng góp cho dự án này.
|
||||
|
||||
## 📄 Giấy phép
|
||||
|
||||
Dự án này đã được cấp Giấy phép MIT.
|
||||
86
docs/README.zh-CN.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 🍥Fuwari
|
||||
|
||||
基于 [Astro](https://astro.build) 开发的静态博客模板。
|
||||
|
||||
[**🖥️在线预览(Vercel)**](https://fuwari.vercel.app)
|
||||
|
||||

|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- [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
@@ -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
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "aecwblog",
|
||||
"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.6",
|
||||
"@astrojs/rss": "^4.0.14",
|
||||
"@astrojs/sitemap": "^3.6.0",
|
||||
"@astrojs/svelte": "7.2.3",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@expressive-code/core": "^0.41.4",
|
||||
"@expressive-code/plugin-collapsible-sections": "^0.41.4",
|
||||
"@expressive-code/plugin-line-numbers": "^0.41.4",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/roboto": "^5.2.9",
|
||||
"@iconify-json/fa6-brands": "^1.2.6",
|
||||
"@iconify-json/fa6-regular": "^1.2.4",
|
||||
"@iconify-json/fa6-solid": "^1.2.4",
|
||||
"@iconify-json/material-symbols": "^1.2.50",
|
||||
"@iconify/svelte": "^4.2.0",
|
||||
"@swup/astro": "^1.7.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"astro": "5.13.10",
|
||||
"astro-expressive-code": "^0.41.4",
|
||||
"astro-icon": "^1.1.5",
|
||||
"hastscript": "^9.0.1",
|
||||
"katex": "^0.16.27",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"overlayscrollbars": "^2.12.0",
|
||||
"pagefind": "^1.4.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.5",
|
||||
"stylus": "^0.64.0",
|
||||
"svelte": "^5.39.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/ts-plugin": "^1.10.6",
|
||||
"@biomejs/biome": "2.2.5",
|
||||
"@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
@@ -0,0 +1,6 @@
|
||||
exclude_selectors:
|
||||
- "span.katex"
|
||||
- "span.katex-display"
|
||||
- "[data-pagefind-ignore]"
|
||||
- ".search-panel"
|
||||
- "#search-panel"
|
||||
11863
pnpm-lock.yaml
generated
Normal file
11
postcss.config.mjs
Normal 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,
|
||||
}
|
||||
};
|
||||
BIN
public/favicon/favicon-dark-128.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/favicon/favicon-dark-180.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/favicon/favicon-dark-192.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/favicon/favicon-dark-32.png
Normal file
|
After Width: | Height: | Size: 426 B |
BIN
public/favicon/favicon-light-128.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/favicon/favicon-light-180.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/favicon/favicon-light-192.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/favicon/favicon-light-32.png
Normal file
|
After Width: | Height: | Size: 554 B |
59
scripts/new-post.js
Normal 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`)
|
||||
BIN
src/assets/images/avatar.jpeg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
src/assets/images/banner.jpg
Normal file
|
After Width: | Height: | Size: 638 KiB |
BIN
src/assets/images/demo-avatar.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
src/assets/images/demo-banner.png
Normal file
|
After Width: | Height: | Size: 877 KiB |
151
src/components/ArchivePanel.svelte
Normal 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, 10),
|
||||
posts: grouped[Number.parseInt(yearStr, 10)],
|
||||
}));
|
||||
|
||||
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>
|
||||
7
src/components/ConfigCarrier.astro
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
|
||||
import { siteConfig } from "../config";
|
||||
---
|
||||
|
||||
<div id="config-carrier" data-hue={siteConfig.themeColor.hue}>
|
||||
</div>
|
||||
21
src/components/Footer.astro
Normal 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(--primary)] mx-16 border-dashed py-8 max-w-[var(--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(--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">
|
||||
© <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>
|
||||
3
src/components/GlobalStyles.astro
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
|
||||
---
|
||||
99
src/components/LightDarkSwitch.svelte
Normal 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
@@ -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>}
|
||||
110
src/components/PostCard.astro
Normal 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>
|
||||
82
src/components/PostMeta.astro
Normal 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>
|
||||
28
src/components/PostPage.astro
Normal 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>
|
||||
198
src/components/Search.svelte
Normal 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>
|
||||
49
src/components/control/BackToTop.astro
Normal 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>
|
||||
43
src/components/control/ButtonLink.astro
Normal 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-[var(--btn-regular-bg)] dark:bg-[var(--primary)]
|
||||
flex items-center justify-center">
|
||||
{ badge }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
</a>
|
||||
13
src/components/control/ButtonTag.astro
Normal 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>
|
||||
84
src/components/control/Pagination.astro
Normal 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>
|
||||
54
src/components/misc/ImageWrapper.astro
Normal 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>
|
||||
43
src/components/misc/License.astro
Normal 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>
|
||||
43
src/components/misc/Markdown.astro
Normal 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>
|
||||
35
src/components/widget/Categories.astro
Normal 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>
|
||||
93
src/components/widget/DisplaySettings.svelte
Normal 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 will-change-transform"
|
||||
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>
|
||||
32
src/components/widget/NavMenuPanel.astro
Normal 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>
|
||||
39
src/components/widget/Profile.astro
Normal 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 flex-wrap 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>
|
||||
|
||||
22
src/components/widget/SideBar.astro
Normal 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>
|
||||
268
src/components/widget/TOC.astro
Normal file
@@ -0,0 +1,268 @@
|
||||
---
|
||||
import type { MarkdownHeading } from "astro";
|
||||
import { siteConfig } from "../../config";
|
||||
import { url } from "../../utils/url-utils";
|
||||
|
||||
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(url("/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" style="opacity: 0" 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 = -1;
|
||||
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--;
|
||||
}
|
||||
if (min > max) {
|
||||
this.activeIndicator?.setAttribute("style", `opacity: 0`);
|
||||
} else {
|
||||
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>
|
||||
31
src/components/widget/Tags.astro
Normal 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>
|
||||
60
src/components/widget/WidgetLayout.astro
Normal 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>
|
||||
91
src/config.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
ExpressiveCodeConfig,
|
||||
LicenseConfig,
|
||||
NavBarConfig,
|
||||
ProfileConfig,
|
||||
SiteConfig,
|
||||
} from "./types/config";
|
||||
import { LinkPreset } from "./types/config";
|
||||
|
||||
export const siteConfig: SiteConfig = {
|
||||
title: "Acircle",
|
||||
subtitle: "AeCw's Blog",
|
||||
lang: "zh_CN", // Language code, e.g. 'en', 'zh_CN', 'ja', etc.
|
||||
themeColor: {
|
||||
hue: 220, // 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: true,
|
||||
src: "assets/images/banner.jpg", // 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,
|
||||
LinkPreset.Download,
|
||||
{
|
||||
name: "星域杯",
|
||||
url: "https://rino.ink/", // 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/avatar.jpeg", // Relative to the /src directory. Relative to the /public directory if it starts with '/'
|
||||
name: "AeCw",
|
||||
bio: "想要变强",
|
||||
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/AECBanana",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
17
src/constants/constants.ts
Normal 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
@@ -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",
|
||||
},
|
||||
];
|
||||
22
src/constants/link-presets.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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/",
|
||||
},
|
||||
[LinkPreset.Download]: {
|
||||
name: i18n(I18nKey.download),
|
||||
url: "/download/",
|
||||
},
|
||||
};
|
||||
28
src/content/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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(""),
|
||||
}),
|
||||
});
|
||||
const specCollection = defineCollection({
|
||||
schema: z.object({}),
|
||||
});
|
||||
export const collections = {
|
||||
posts: postsCollection,
|
||||
spec: specCollection,
|
||||
};
|
||||
21
src/content/posts/init.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: init
|
||||
published: 2026-01-10
|
||||
description: '博客简述'
|
||||
image: ''
|
||||
tags: ["聊聊","日记"]
|
||||
category: '聊聊'
|
||||
draft: false
|
||||
lang: ''
|
||||
---
|
||||
|
||||
# Hello world
|
||||
|
||||
这是第3个博客网站了,前两个因为管理不到位,加上服务器运行、没有及时更新、没人看、没钱等原因。这次就使用vercel进行部署好了。
|
||||
|
||||
最近的星域杯也即将完结,选手还剩下4位,虽然现在staff还没把GF图池给选出来。゚ ∀゚)ノ
|
||||
其实我想选FR的,但好像太折磨选手了。
|
||||
|
||||
今天有个新的单子,做AR加UI设计的,不知道那位妹妹被老师要求的。我想到的方案就是threejs放个模型上去展示,然后加一个摄像头进行模拟ar环境。但这也让我搞了一天了。是能够实现,但还是有点缺陷,我不知道怎么使用webxr来进行镜头移动模型就可以移动观看的。可能需要app吧。网页的webxr好像是给头显使用的。考虑到这种工程加上给我单的同学要给她写论文,报价6000应该不过分把。。。大概(´_っ`)
|
||||
|
||||
今晚想吃烧烤说是,已经饿了,待会就点一个。
|
||||
11
src/content/spec/about.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# About
|
||||
|
||||
blog使用[Fuwari](https://github.com/saicaca/fuwari).
|
||||
|
||||
第三次创业(blog),前两次都是服务器运营太贵了,加上没有维护的精力导致的。
|
||||
|
||||
> ### 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)
|
||||
3
src/content/spec/download.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Download
|
||||
|
||||
下载区
|
||||
2
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
41
src/global.d.ts
vendored
Normal 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[];
|
||||
}
|
||||
38
src/i18n/i18nKey.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
enum I18nKey {
|
||||
home = "home",
|
||||
about = "about",
|
||||
archive = "archive",
|
||||
search = "search",
|
||||
download = "download",
|
||||
|
||||
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
@@ -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
@@ -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/id.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import Key from "../i18nKey";
|
||||
import type { Translation } from "../translation";
|
||||
|
||||
export const id: Translation = {
|
||||
[Key.home]: "Beranda",
|
||||
[Key.about]: "Tentang",
|
||||
[Key.archive]: "Arsip",
|
||||
[Key.search]: "Cari",
|
||||
|
||||
[Key.tags]: "Tag",
|
||||
[Key.categories]: "Kategori",
|
||||
[Key.recentPosts]: "Postingan Terbaru",
|
||||
|
||||
[Key.comments]: "Komentar",
|
||||
|
||||
[Key.untitled]: "Tanpa Judul",
|
||||
[Key.uncategorized]: "Tanpa Kategori",
|
||||
[Key.noTags]: "Tanpa Tag",
|
||||
|
||||
[Key.wordCount]: "kata",
|
||||
[Key.wordsCount]: "kata",
|
||||
[Key.minuteCount]: "menit",
|
||||
[Key.minutesCount]: "menit",
|
||||
[Key.postCount]: "postingan",
|
||||
[Key.postsCount]: "postingan",
|
||||
|
||||
[Key.themeColor]: "Warna Tema",
|
||||
|
||||
[Key.lightMode]: "Terang",
|
||||
[Key.darkMode]: "Gelap",
|
||||
[Key.systemMode]: "Sistem",
|
||||
|
||||
[Key.more]: "Lainnya",
|
||||
|
||||
[Key.author]: "Penulis",
|
||||
[Key.publishedAt]: "Diterbitkan pada",
|
||||
[Key.license]: "Lisensi",
|
||||
};
|
||||
38
src/i18n/languages/ja.ts
Normal 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
@@ -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
@@ -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]: "สัญญาอนุญาต",
|
||||
};
|
||||
38
src/i18n/languages/tr.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import Key from "../i18nKey";
|
||||
import type { Translation } from "../translation";
|
||||
|
||||
export const tr: Translation = {
|
||||
[Key.home]: "Anasayfa",
|
||||
[Key.about]: "Hakkında",
|
||||
[Key.archive]: "Arşiv",
|
||||
[Key.search]: "Ara",
|
||||
|
||||
[Key.tags]: "Taglar",
|
||||
[Key.categories]: "Katagoriler",
|
||||
[Key.recentPosts]: "Son Paylaşımlar",
|
||||
|
||||
[Key.comments]: "Yorumlar",
|
||||
|
||||
[Key.untitled]: "Başlıksız",
|
||||
[Key.uncategorized]: "Katagorisiz",
|
||||
[Key.noTags]: "Tag Bulunamadı",
|
||||
|
||||
[Key.wordCount]: "kelime",
|
||||
[Key.wordsCount]: "kelime",
|
||||
[Key.minuteCount]: "dakika",
|
||||
[Key.minutesCount]: "dakika",
|
||||
[Key.postCount]: "gönderi",
|
||||
[Key.postsCount]: "gönderiler",
|
||||
|
||||
[Key.themeColor]: "Tema Rengi",
|
||||
|
||||
[Key.lightMode]: "Aydınlık",
|
||||
[Key.darkMode]: "Koyu",
|
||||
[Key.systemMode]: "Sistem",
|
||||
|
||||
[Key.more]: "Daha Fazla",
|
||||
|
||||
[Key.author]: "Yazar",
|
||||
[Key.publishedAt]: "Yayınlanma:",
|
||||
[Key.license]: "Lisans",
|
||||
};
|
||||
38
src/i18n/languages/vi.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import Key from "../i18nKey";
|
||||
import type { Translation } from "../translation";
|
||||
|
||||
export const vi: Translation = {
|
||||
[Key.home]: "Trang chủ",
|
||||
[Key.about]: "Giới thiệu",
|
||||
[Key.archive]: "Kho bài",
|
||||
[Key.search]: "Tìm kiếm",
|
||||
|
||||
[Key.tags]: "Thẻ",
|
||||
[Key.categories]: "Danh mục",
|
||||
[Key.recentPosts]: "Bài viết mới nhất",
|
||||
|
||||
[Key.comments]: "Bình luận",
|
||||
|
||||
[Key.untitled]: "Không tiêu đề",
|
||||
[Key.uncategorized]: "Chưa phân loại",
|
||||
[Key.noTags]: "Chưa có thẻ",
|
||||
|
||||
[Key.wordCount]: "từ",
|
||||
[Key.wordsCount]: "từ",
|
||||
[Key.minuteCount]: "phút đọc",
|
||||
[Key.minutesCount]: "phút đọc",
|
||||
[Key.postCount]: "bài viết",
|
||||
[Key.postsCount]: "bài viết",
|
||||
|
||||
[Key.themeColor]: "Màu giao diện",
|
||||
|
||||
[Key.lightMode]: "Sáng",
|
||||
[Key.darkMode]: "Tối",
|
||||
[Key.systemMode]: "Hệ thống",
|
||||
|
||||
[Key.more]: "Thêm",
|
||||
|
||||
[Key.author]: "Tác giả",
|
||||
[Key.publishedAt]: "Đăng vào lúc",
|
||||
[Key.license]: "Giấy phép bản quyền",
|
||||
};
|
||||
39
src/i18n/languages/zh_CN.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import Key from "../i18nKey";
|
||||
import type { Translation } from "../translation";
|
||||
|
||||
export const zh_CN: Translation = {
|
||||
[Key.home]: "主页",
|
||||
[Key.about]: "关于",
|
||||
[Key.archive]: "归档",
|
||||
[Key.search]: "搜索",
|
||||
[Key.download]: "下载",
|
||||
|
||||
[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/zh_TW.ts
Normal 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]: "許可協議",
|
||||
};
|
||||
48
src/i18n/translation.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { siteConfig } from "../config";
|
||||
import type I18nKey from "./i18nKey";
|
||||
import { en } from "./languages/en";
|
||||
import { es } from "./languages/es";
|
||||
import { id } from "./languages/id";
|
||||
import { ja } from "./languages/ja";
|
||||
import { ko } from "./languages/ko";
|
||||
import { th } from "./languages/th";
|
||||
import { tr } from "./languages/tr";
|
||||
import { vi } from "./languages/vi";
|
||||
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,
|
||||
vi: vi,
|
||||
vi_vn: vi,
|
||||
id: id,
|
||||
tr: tr,
|
||||
tr_tr: tr,
|
||||
};
|
||||
|
||||
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
@@ -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>
|
||||
125
src/layouts/MainGridLayout.astro
Normal 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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
25
src/pages/download.astro
Normal 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", "download");
|
||||
|
||||
if (!aboutPost) {
|
||||
throw new Error("Download page content not found");
|
||||
}
|
||||
|
||||
const { Content } = await render(aboutPost);
|
||||
---
|
||||
<MainGridLayout title={i18n(I18nKey.download)} description={i18n(I18nKey.download)}>
|
||||
<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>
|
||||
136
src/pages/posts/[...slug].astro
Normal 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
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
42
src/pages/rss.xml.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import rss from "@astrojs/rss";
|
||||
import { getSortedPosts } from "@utils/content-utils";
|
||||
import { url } from "@utils/url-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: url(`/posts/${post.slug}/`),
|
||||
content: sanitizeHtml(parser.render(cleanedContent), {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
|
||||
}),
|
||||
};
|
||||
}),
|
||||
customData: `<language>${siteConfig.lang}</language>`,
|
||||
});
|
||||
}
|
||||
90
src/plugins/expressive-code/custom-copy-button.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
50
src/plugins/expressive-code/language-badge.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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-expect-error
|
||||
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-family: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
33
src/plugins/rehype-component-admonition.mjs
Normal 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,
|
||||
]);
|
||||
}
|
||||
95
src/plugins/rehype-component-github-card.mjs
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
30
src/plugins/remark-directive-rehype.js
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||