This commit is contained in:
2025-07-18 16:43:10 +02:00
parent ad40616249
commit 8c37084d94
94 changed files with 14759 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Build output
dist/
.output/
# 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
# IDE settings
.idea/
# Netlify build output
.netlify/

40
.prettierignore Normal file
View File

@@ -0,0 +1,40 @@
# Build output
dist/
.output/
# Dependencies
node_modules/
# Package manager files
package-lock.json
yarn.lock
pnpm-lock.yaml
# Log files
*.log
# Environment variables
.env
.env.*
# Cache directories
.cache/
.astro/
# Others
.DS_Store
coverage/
# Media files
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.ico
*.webp
*.mp4
*.webm
*.mp3
*.wav
*.pdf

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"plugins": ["prettier-plugin-astro"],
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"tabWidth": 2
}

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

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

21
LICENSE Normal file
View File

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

53
astro.config.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineConfig } from 'astro/config'
import mdx from '@astrojs/mdx'
import sitemap from '@astrojs/sitemap'
import playformInline from '@playform/inline'
import remarkMath from 'remark-math'
import remarkDirective from 'remark-directive'
import rehypeKatex from 'rehype-katex'
import remarkEmbeddedMedia from './src/plugins/remark-embedded-media.mjs'
import remarkReadingTime from './src/plugins/remark-reading-time.mjs'
import rehypeCleanup from './src/plugins/rehype-cleanup.mjs'
import rehypeImageProcessor from './src/plugins/rehype-image-processor.mjs'
import rehypeCopyCode from './src/plugins/rehype-copy-code.mjs'
import remarkTOC from './src/plugins/remark-toc.mjs'
import { themeConfig } from './src/config'
import { imageConfig } from './src/utils/image-config'
import path from 'path'
import netlify from '@astrojs/netlify'
export default defineConfig({
adapter: netlify(), // Set adapter for deployment
site: themeConfig.site.website,
image: {
service: {
entrypoint: 'astro/assets/services/sharp',
config: imageConfig
}
},
markdown: {
shikiConfig: {
theme: 'css-variables',
wrap: false
},
remarkPlugins: [remarkMath, remarkDirective, remarkEmbeddedMedia, remarkReadingTime, remarkTOC],
rehypePlugins: [rehypeKatex, rehypeCleanup, rehypeImageProcessor, rehypeCopyCode]
},
integrations: [
playformInline({
Exclude: [(file) => file.toLowerCase().includes('katex')]
}),
mdx(),
sitemap()
],
vite: {
resolve: {
alias: {
'@': path.resolve('./src')
}
}
},
devToolbar: {
enabled: false
}
})

41
eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
import eslint from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import prettier from 'eslint-config-prettier'
import eslintPluginAstro from 'eslint-plugin-astro'
export default [
eslint.configs.recommended,
...tseslint.configs.recommended,
...eslintPluginAstro.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
rules: {
'no-console': ['warn', { allow: ['warn', 'error'] }],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'astro/no-set-html-directive': 'off'
}
},
prettier,
{
ignores: [
'dist/**',
'.output/**',
'node_modules/**',
'*.log',
'.env*',
'.cache/**',
'.astro/**',
'.DS_Store',
'coverage/**'
]
}
]

109
netlify.toml Normal file
View File

@@ -0,0 +1,109 @@
[build]
publish = "dist"
command = "pnpm run build"
# Redirects for Astro static assets
[[redirects]]
from = "/_astro/*"
to = "/_astro/:splat"
status = 200
# Enable Brotli Compression
[build.processing]
skip_processing = false
[build.processing.css]
bundle = true
minify = true
[build.processing.js]
bundle = true
minify = true
[build.processing.html]
pretty_urls = true
# Global headers
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "SAMEORIGIN"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "geolocation=(), microphone=(), camera=(), fullscreen=(self)"
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; font-src 'self' data: https:; frame-src https: player.bilibili.com; connect-src 'self' https:; object-src 'none'; worker-src 'self' blob:; child-src 'self' blob: player.bilibili.com;"
# HTML pages
[[headers]]
for = "/*.html"
[headers.values]
Cache-Control = "public, max-age=86400, s-maxage=172800"
Vary = "Accept-Encoding"
# Blog posts
[[headers]]
for = "/*/"
[headers.values]
Cache-Control = "public, max-age=86400, s-maxage=172800"
Vary = "Accept-Encoding"
# Homepage
[[headers]]
for = "/"
[headers.values]
Cache-Control = "public, max-age=1800, s-maxage=3600"
Vary = "Accept-Encoding"
# Static assets
[[headers]]
for = "/fonts/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Vary = "Accept-Encoding"
[[headers]]
for = "/_astro/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Vary = "Accept-Encoding"
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Vary = "Accept-Encoding"
[[headers]]
for = "/*.css"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Vary = "Accept-Encoding"
[[headers]]
for = "/*.js"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Vary = "Accept-Encoding"
# Images
[[headers]]
for = "/*.jpg"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Vary = "Accept-Encoding"
[[headers]]
for = "/*.png"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Vary = "Accept-Encoding"
[[headers]]
for = "/*.webp"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Vary = "Accept-Encoding"

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "astro-chiri",
"type": "module",
"version": "0.5.0",
"license": "MIT",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"new": "tsx scripts/new-post.ts",
"update-theme": "tsx scripts/update-theme.ts"
},
"dependencies": {
"@astrojs/mdx": "^4.3.1",
"@astrojs/netlify": "^6.5.1",
"@astrojs/sitemap": "^3.4.1",
"@playform/inline": "^0.1.2",
"astro": "^5.12.0",
"astro-og-canvas": "^0.7.0",
"canvaskit-wasm": "^0.40.0",
"katex": "^0.16.22",
"mdast-util-to-string": "^4.0.0",
"reading-time": "^1.5.0",
"rehype-katex": "^7.0.1",
"remark-directive": "^4.0.0",
"remark-math": "^6.0.0",
"sharp": "^0.34.3",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.31.0",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.37.0",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-astro": "^1.3.1",
"globals": "^16.3.0",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.37.0"
}
}

8795
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

7
public/favicon.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
id="favicon-path"
d="M12.3785 2C16.2799 2.0001 15.793 5.9958 15.3053 7.99414C16.7687 6.99515 19.8498 5.92943 21.6451 8.99414C23.1079 11.4917 19.6947 14.3221 17.7437 14.9883C19.2069 15.9875 21.3523 18.5847 18.231 20.9824C15.1095 23.3803 12.7031 20.983 11.8902 19.4844C10.9146 20.983 8.28152 23.3801 5.55039 20.9824C2.81905 18.5846 4.41233 16.3199 5.55039 15.4873C3.76201 14.9878 0.672689 12.0909 2.62363 8.49414C3.85202 6.22947 7.01339 6.66254 7.98886 7.49512C7.6637 5.66344 8.47661 2 12.3785 2ZM12.3121 9.49316C8.42708 9.49327 7.3044 16.1247 12.3121 15.4375C15.6222 14.9833 15.6814 9.49316 12.3121 9.49316Z"
fill="#111"
/>
</svg>

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

BIN
public/fonts/Inter.woff2 Normal file

Binary file not shown.

1
public/katex.min.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
public/og/chiri-og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
public/og/og-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
public/og/og-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

55
scripts/new-post.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Create a new post with frontmatter
* Usage: pnpm new <title>
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import process from 'node:process'
// Process title from all arguments
const titleArgs: string[] = process.argv.slice(2)
const rawTitle: string = titleArgs.length > 0 ? titleArgs.join(' ') : 'new-post'
// Check if title starts with underscore (draft post)
const isDraft: boolean = rawTitle.startsWith('_')
const displayTitle: string = isDraft ? rawTitle.slice(1) : rawTitle
const fileName: string = rawTitle
.toLowerCase()
.replace(/[^a-z0-9\s-_]/g, '') // Remove special characters but keep underscore and hyphen
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
const targetFile: string = `${fileName}.md`
const fullPath: string = join('src/content/posts', targetFile)
// Check if the target file already exists
if (existsSync(fullPath)) {
console.error(`😇 File already exists: ${fullPath}`)
process.exit(1)
}
// Ensure the directory structure exists
mkdirSync(dirname(fullPath), { recursive: true })
// Generate frontmatter with current date
const content: string = `---
title: ${displayTitle}
pubDate: '${new Date().toISOString().split('T')[0]}'
---
`
// Write the new post file
try {
writeFileSync(fullPath, content)
if (isDraft) {
console.log(`📝 Draft created: ${fullPath}`)
} else {
console.log(`✅ Post created: ${fullPath}`)
}
} catch (error) {
console.error('⚠️ Failed to create post:', error)
process.exit(1)
}

46
scripts/update-theme.ts Normal file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env tsx
/**
* Update theme from upstream repository
* Usage: pnpm update-theme
*/
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
// Check and set up the upstream remote repository
try {
execSync('git remote get-url upstream', { stdio: 'ignore' })
} catch {
execSync('git remote add upstream https://github.com/the3ash/astro-chiri.git', {
stdio: 'inherit'
})
}
// Update theme from upstream repository
try {
execSync('git fetch upstream', { stdio: 'inherit' })
const currentCommit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim()
execSync('git merge upstream/main --allow-unrelated-histories', { stdio: 'inherit' })
const newCommit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim()
if (currentCommit === newCommit) {
console.log('🤗 No updates available, already up to date')
} else {
console.log('✅ Theme updated')
}
} catch (error) {
// Check if there's a merge conflict
const gitDirectory = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim()
const mergeHeadFile = path.join(gitDirectory, 'MERGE_HEAD')
if (fs.existsSync(mergeHeadFile)) {
console.log('⚠️ Update fetched with merge conflicts. Please resolve manually')
} else {
console.error('❌ Update failed:', error)
process.exit(1)
}
}

View File

@@ -0,0 +1,53 @@
<div class="callout-card">
<div class="callout-container">
<div class="callout">
<span class="callout-icon">🙊</span>
<span class="callout-text">Ape shall never kill ape.</span>
</div>
</div>
</div>
<style>
.callout-card {
border: 1px solid var(--border);
width: 100%;
height: 12rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
}
.callout-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 0 4rem;
}
.callout {
background-color: var(--astro-code-background);
border: 1px solid var(--code-bg);
border-radius: 10px;
padding: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-width: 13rem;
}
.callout-icon {
padding-right: 0.5rem;
font-size: 1.25rem;
line-height: 1;
display: flex;
align-items: center;
}
.callout-text {
color: var(--text-primary);
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,60 @@
<div class="counter-card">
<div class="counter-container">
<button id="counter-btn" class="counter-button">
Click me (<span id="counter-value">0</span>)
</button>
</div>
</div>
<script is:inline>
let count = 0
function setupCounter() {
const button = document.getElementById('counter-btn')
const valueSpan = document.getElementById('counter-value')
if (button && valueSpan) {
valueSpan.textContent = count.toString()
button.onclick = function () {
count++
valueSpan.textContent = count.toString()
}
}
}
setupCounter()
document.addEventListener('astro:page-load', setupCounter)
</script>
<style>
.counter-card {
border: 1px solid var(--border);
width: 100%;
height: 12rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
}
.counter-container {
display: flex;
justify-content: center;
align-items: center;
}
.counter-button {
background-color: var(--text-primary);
color: var(--bg);
border: none;
font-family: var(--mono);
font-size: var(--font-size-s);
border-radius: 12px;
cursor: pointer;
width: 160px;
height: 44px;
transition: all 0.15s ease-in-out;
}
.counter-button:hover {
opacity: 0.9;
}
</style>

View File

@@ -0,0 +1,479 @@
<div class="tag-card">
<div class="tag-container">
<div id="tag-component" class="tag-component">
<button id="add-button" class="add-button">
<svg
class="add-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="16"
height="16"
>
<path
d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"
></path>
</svg>
</button>
<div id="input-state" class="input-state">
<input id="tag-input" type="text" placeholder="Tag Name" class="tag-input" />
<button id="confirm-button" class="confirm-button disabled">
<svg
class="confirm-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="16"
height="16"
>
<path
d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"
></path>
</svg>
</button>
<button id="cancel-button" class="cancel-button">
<svg
class="cancel-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="16"
height="16"
>
<path
d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"
></path>
</svg>
</button>
</div>
<div id="tag-display" class="tag-display">
<span id="tag-text" class="tag-text"></span>
<button id="delete-button" class="delete-button">
<svg
class="delete-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="16"
height="16"
>
<path
d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"
></path>
</svg>
</button>
</div>
</div>
</div>
</div>
<script is:inline>
function setupTag() {
let currentState = 'add' // 'add', 'input', 'display'
let tagValue = localStorage.getItem('tagValue') || ''
const addButton = document.getElementById('add-button')
const inputState = document.getElementById('input-state')
const tagDisplay = document.getElementById('tag-display')
const tagInput = document.getElementById('tag-input')
const confirmButton = document.getElementById('confirm-button')
const cancelButton = document.getElementById('cancel-button')
const deleteButton = document.getElementById('delete-button')
const tagText = document.getElementById('tag-text')
if (tagValue) {
addButton.classList.add('hidden')
inputState.classList.remove('active')
tagDisplay.classList.add('active')
tagText.textContent = truncateText(tagValue)
currentState = 'display'
} else {
addButton.classList.remove('hidden')
inputState.classList.remove('active')
tagDisplay.classList.remove('active')
tagInput.value = ''
currentState = 'add'
}
// Switch to input state
function switchToInput() {
currentState = 'input'
addButton.classList.add('hidden')
setTimeout(() => {
inputState.classList.add('active')
tagInput.focus()
}, 100)
}
// Switch to display state
function switchToDisplay() {
currentState = 'display'
inputState.classList.remove('active')
setTimeout(() => {
tagDisplay.classList.add('active')
tagText.textContent = truncateText(tagValue)
// Store to localStorage
localStorage.setItem('tagValue', tagValue)
}, 300)
}
// Return to add state
function switchToAdd() {
if (currentState === 'display') {
tagDisplay.classList.remove('active')
} else if (currentState === 'input') {
inputState.classList.remove('active')
}
currentState = 'add'
setTimeout(() => {
addButton.classList.remove('hidden')
tagValue = ''
tagInput.value = ''
localStorage.removeItem('tagValue')
updateConfirmButton()
}, 250)
}
// Update confirm button state
function updateConfirmButton() {
if (tagInput.value.trim()) {
confirmButton.classList.remove('disabled')
} else {
confirmButton.classList.add('disabled')
}
}
// Truncate text to max 24 characters
function truncateText(text, maxLength = 24) {
if (text.length <= maxLength) {
return text
}
return text.substring(0, maxLength) + '...'
}
// Bind events
addButton.onclick = switchToInput
tagInput.oninput = function (e) {
const inputValue = e.target.value
if (inputValue.length > 24) {
e.target.value = inputValue.substring(0, 24)
tagValue = inputValue
} else {
tagValue = inputValue
}
updateConfirmButton()
}
tagInput.onkeydown = function (e) {
if (e.key === 'Enter' && tagValue.trim()) {
switchToDisplay()
} else if (e.key === 'Escape') {
switchToAdd()
}
}
confirmButton.onclick = function () {
if (tagValue.trim()) {
switchToDisplay()
}
}
cancelButton.onclick = switchToAdd
deleteButton.onclick = switchToAdd
}
setupTag()
document.addEventListener('astro:page-load', setupTag)
</script>
<style>
.tag-card {
border: 1px solid var(--border);
width: 100%;
height: 12rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
}
.tag-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 0 2rem;
overflow: hidden;
}
.tag-component {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
position: relative;
width: 100%;
min-height: 40px;
}
.add-button {
background-color: var(--text-primary);
color: var(--bg);
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
all 0.3s ease,
visibility 0s;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(1);
z-index: 1;
opacity: 1;
visibility: visible;
overflow: visible;
will-change: transform;
-webkit-transform: translate(-50%, -50%) scale(1);
box-sizing: border-box;
padding: 0;
margin: 0;
}
.add-button.hidden {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
pointer-events: none;
visibility: hidden;
transition:
all 0.3s ease,
visibility 0s 0.2s;
}
.add-icon,
.confirm-icon,
.cancel-icon,
.delete-icon {
fill: currentColor;
width: 1rem;
height: 1rem;
position: relative;
display: block;
transform: translateZ(0);
}
.add-button:hover {
opacity: 0.8;
transform: translate(-50%, -50%) scale(1);
-webkit-transform: translate(-50%, -50%) scale(1);
}
.input-state {
display: flex;
align-items: center;
gap: 0.125rem;
background-color: var(--astro-code-background);
border: 1px solid var(--code-bg);
border-radius: 18px;
padding: 0.28125rem 0.375rem;
width: 40px;
max-width: 40px;
opacity: 0;
overflow: hidden;
transition: all 0.3s ease;
position: relative;
z-index: 2;
pointer-events: none;
}
.input-state.active {
width: 10rem;
max-width: 10rem;
opacity: 1;
pointer-events: all;
}
.tag-input {
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-size: 0.9rem;
font-family: var(--mono);
min-width: 4rem;
padding: 0.25rem 0.5rem;
opacity: 0;
transform: translateX(-8px);
transition:
opacity 0.2s ease 0.15s,
transform 0.2s ease 0.15s;
}
.input-state.active .tag-input {
opacity: 1;
transform: translateX(0);
}
.tag-input::placeholder {
color: var(--text-tertiary);
}
.confirm-button,
.cancel-button {
background: none;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
opacity: 0;
transform: scale(0.8);
transition: all 0.2s ease;
position: relative;
min-width: 24px;
min-height: 24px;
}
.confirm-button::before,
.cancel-button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background-color: var(--code-bg);
opacity: 0;
transition: opacity 0.2s ease;
z-index: -1;
width: 28px;
height: 28px;
}
.confirm-button:hover::before,
.cancel-button:hover::before {
opacity: 1;
}
.input-state.active .confirm-button,
.input-state.active .cancel-button {
transform: scale(1);
}
.confirm-button {
color: var(--text-primary);
opacity: 0.6;
}
.confirm-button.disabled {
opacity: 0.3;
cursor: not-allowed;
}
.confirm-button:not(.disabled):hover {
opacity: 1;
}
.confirm-button.disabled:hover::before {
opacity: 0;
}
.cancel-button {
color: var(--text-primary);
opacity: 0.6;
}
.cancel-button:hover {
opacity: 1;
}
.tag-display {
display: flex;
align-items: center;
gap: 0.25rem;
background-color: var(--astro-code-background);
border: 1px solid var(--code-bg);
border-radius: 18px;
padding: 0.28125rem 0.325rem 0.28125rem 0.75rem;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 3;
opacity: 0;
pointer-events: none;
transition: all 0.25s ease-out;
will-change: transform, opacity;
}
.tag-display > * {
margin-right: 0.125rem;
}
.tag-display > *:last-child {
margin-right: 0;
}
.tag-display.active {
opacity: 1;
transform: translate(-50%, -50%);
pointer-events: all;
}
.tag-text {
color: var(--text-primary);
font-size: 0.9rem;
font-family: var(--mono);
}
.delete-button {
background: none;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
color: var(--text-primary);
opacity: 0.6;
transition: all 0.2s ease;
position: relative;
min-width: 24px;
min-height: 24px;
}
.delete-button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background-color: var(--code-bg);
opacity: 0;
transition: opacity 0.2s ease;
z-index: -1;
width: 28px;
height: 28px;
}
.delete-button:hover {
opacity: 1;
}
.delete-button:hover::before {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,58 @@
---
// Import the global.css file here so that it is included on
// all pages through the use of the <BaseHead /> component.
import { themeConfig } from '@/config'
import type { BaseHeadProps } from '@/types/component.types'
import 'katex/dist/katex.min.css'
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const { title, description, ogImage } = Astro.props as BaseHeadProps
const imageUrl = ogImage ? new URL(ogImage, Astro.url) : new URL('/og/chiri-og.png', Astro.url)
---
<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link
rel="preload"
href="/fonts/Besley-Italic.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link rel="sitemap" href="/sitemap-index.xml" />
<link
rel="alternate"
type="application/rss+xml"
title={themeConfig.site.title}
href={new URL('rss.xml', Astro.site)}
/>
<meta name="generator" content={Astro.generator} />
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />
<!-- Primary Meta Tags -->
<title>
{title || themeConfig.site.title}
</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={imageUrl} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={imageUrl} />

View File

@@ -0,0 +1,59 @@
---
import { themeConfig } from '@/config'
const today = new Date()
---
<footer>
<div class="footer-content">
<div class="copyright">
<span class="date">
&copy;
{today.getFullYear()}
</span>
{themeConfig.site.author}
</div>
<div class="powered-by">
Powered by{' '}
<a href="https://astro.build">Astro</a> &{' '}
<a href="https://github.com/the3ash/astro-chiri">Chiri</a>
</div>
</div>
</footer>
<style>
footer {
font-size: var(--font-size-s);
font-weight: var(--font-weight-light);
line-height: 1.75;
color: var(--text-secondary);
opacity: 0.75;
margin-top: 4rem;
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
flex-wrap: nowrap;
width: 100%;
}
.copyright,
.powered-by {
white-space: nowrap;
}
.copyright .date {
opacity: 1;
}
footer a {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s ease-out;
}
footer a:hover {
color: var(--text-primary);
}
</style>

View File

@@ -0,0 +1,28 @@
---
import { themeConfig } from '@/config'
import ThemeToggle from '@/components/ui/ThemeToggle.astro'
---
<header>
<nav>
<a href="/">{themeConfig.site.title}</a>
<ThemeToggle />
</nav>
</header>
<style>
header {
margin: 0;
}
nav {
display: flex;
justify-content: space-between;
align-items: center;
}
header a {
font-weight: var(--font-weight-bold);
color: var(--text-primary);
text-decoration: none;
min-width: 3rem;
display: inline-block;
}
</style>

View File

@@ -0,0 +1,55 @@
---
import type { TransitionProps } from '@/types'
type Props = TransitionProps
const { class: className = '' } = Astro.props
const transitionName = 'content'
---
<div transition:name={transitionName} class={className} id="transition-wrapper">
<slot />
</div>
<style is:inline>
@supports (view-transition-name: none) {
@media not (prefers-reduced-motion: reduce) {
::view-transition-old(content) {
animation: fade-out 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
::view-transition-new(content) {
opacity: 0;
animation: fade-in 0.4s ease 0.2s forwards;
will-change: filter, opacity, transform;
}
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fade-in {
0% {
opacity: 0;
filter: blur(8px);
transform: translateZ(0);
}
30% {
opacity: 0.5;
filter: blur(4px);
transform: translateZ(0);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateZ(0);
}
}
</style>

View File

@@ -0,0 +1,121 @@
---
import { themeConfig } from '@/config'
---
<a href="/" class="back-button">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.5 6.5H9.5C11.1569 6.5 12.5 7.84315 12.5 9.5V9.5C12.5 11.1569 11.1569 12.5 9.5 12.5H7.5M2.5 6.5L5.5 9.5M2.5 6.5L5.5 3.5"
stroke="currentColor"
stroke-width="1.25"
stroke-linecap="round"
stroke-linejoin="round"></path>
</svg>
index
</a>
<script
is:inline
define:vars={{
contentWidth: themeConfig.general.contentWidth,
centeredLayout: themeConfig.general.centeredLayout,
toc: themeConfig.post.toc
}}
>
;(function () {
// Adjust back button position based on layout and screen size
function adjustBackButtonPosition() {
const button = document.querySelector('.back-button')
if (!button) return
// If not using centered layout, remove fixed positioning
if (!centeredLayout) {
button.classList.remove('fixed-position')
button.style.left = ''
return
}
// Calculate available margin space for positioning
const pageWidth = window.innerWidth
const contentWidthValue = parseFloat(contentWidth)
// Apply the same minimum width logic as in BaseLayout
const widthValue = Math.min(contentWidthValue, 50)
const shouldUseCustomWidth = widthValue > 25
const finalWidthValue = shouldUseCustomWidth ? widthValue : 25
const margin = (pageWidth - finalWidthValue * 16) / 2
const baseMinSpace = 11 * 16 // Base minimum space needed
// If toc is enabled, need additional 2.5rem (40px) space
const minSpace = toc ? baseMinSpace + 52 : baseMinSpace + 12
// Position button fixed on the left if there's enough space
if (margin >= minSpace) {
button.classList.add('fixed-position')
const basePosition = margin - baseMinSpace
// If toc is enabled, move 2.5rem (40px) further left
const leftPosition = toc ? basePosition - 40 : basePosition
button.style.left = `${leftPosition}px`
} else {
button.classList.remove('fixed-position')
button.style.left = ''
}
}
adjustBackButtonPosition()
document.addEventListener('astro:page-load', () => {
adjustBackButtonPosition()
})
document.addEventListener('DOMContentLoaded', () => {
adjustBackButtonPosition()
})
window.addEventListener('resize', adjustBackButtonPosition)
})()
</script>
<style>
.back-button {
width: 8rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: var(--serif);
font-size: var(--font-size-m);
font-style: italic;
letter-spacing: 0;
line-height: 1.75;
color: var(--text-secondary);
cursor: pointer;
border: none;
background-color: transparent;
position: relative;
margin-bottom: 2.5em;
padding: 0;
left: -0.175em;
transition: color 0.2s ease-out;
text-decoration: none;
}
.back-button:hover {
color: var(--text-primary);
}
@media (hover: none) and (pointer: coarse) {
.back-button:hover {
color: var(--text-secondary);
}
}
.back-button svg {
width: 0.8rem;
height: 0.8rem;
flex-shrink: 0;
}
.back-button.fixed-position {
position: fixed;
top: 6rem;
margin-bottom: 0;
padding-left: 0.75rem;
z-index: 10;
}
</style>

View File

@@ -0,0 +1,181 @@
---
import { themeConfig } from '@/config'
---
<script define:vars={{ copyCode: themeConfig.post.copyCode }}>
function initCopyCode() {
const copyIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path>
</svg>
`
const copiedIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path>
</svg>
`
document.documentElement.setAttribute('data-copy-code', copyCode ? 'enabled' : 'disabled')
if (!copyCode) {
return
}
const copyButtons = document.querySelectorAll('.copy-button')
copyButtons.forEach((button) => {
const preElement = button.closest('.copy-code-block')
if (!preElement) return
const codeElement = preElement.querySelector('code')
if (!codeElement) return
if (!button.querySelector('svg')) {
button.innerHTML = copyIcon
}
button.style.opacity = '0'
button.style.pointerEvents = 'none'
preElement.addEventListener('mouseenter', () => {
button.style.opacity = '1'
button.style.pointerEvents = 'auto'
})
preElement.addEventListener('mouseleave', () => {
if (!button.hasAttribute('data-copying')) {
button.style.opacity = '0'
button.style.pointerEvents = 'none'
}
})
button.addEventListener('click', async () => {
const codeText = codeElement.textContent || ''
try {
// Primary method: use modern clipboard API
await navigator.clipboard.writeText(codeText)
button.setAttribute('data-copying', 'true')
button.innerHTML = copiedIcon
setTimeout(() => {
if (!preElement.matches(':hover')) {
button.style.opacity = '0'
button.style.pointerEvents = 'none'
}
button.removeAttribute('data-copying')
setTimeout(() => {
button.innerHTML = copyIcon
}, 500)
}, 1500)
} catch (err) {
console.error('Failed to copy code:', err)
// Fallback method: create temporary textarea for older browsers
try {
const textArea = document.createElement('textarea')
textArea.value = codeText
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
navigator.clipboard
.writeText(codeText)
.then(() => {
document.body.removeChild(textArea)
})
.catch(() => {
console.error('Both clipboard methods failed')
document.body.removeChild(textArea)
})
} catch (fallbackErr) {
console.error('All clipboard methods failed:', fallbackErr)
}
button.setAttribute('data-copying', 'true')
button.innerHTML = copiedIcon
setTimeout(() => {
if (!preElement.matches(':hover')) {
button.style.opacity = '0'
button.style.pointerEvents = 'none'
}
button.removeAttribute('data-copying')
setTimeout(() => {
button.innerHTML = copyIcon
}, 500)
}, 1500)
}
})
})
}
document.addEventListener('astro:page-load', initCopyCode)
document.addEventListener('DOMContentLoaded', initCopyCode)
</script>
<style is:inline>
.copy-code-block {
position: relative !important;
}
.copy-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 2rem;
height: 2rem;
z-index: 10;
background: var(--bg);
border-radius: 0.375rem;
font-size: 0.75rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease-out;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
backdrop-filter: blur(48px);
opacity: 0;
pointer-events: none;
}
[data-copy-code='disabled'] .copy-button {
display: none !important;
}
.copy-button::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--code-bg);
border-radius: 0.325rem;
opacity: 0;
transition: opacity 0.15s ease-out;
pointer-events: none;
}
.copy-button:hover::before {
opacity: 1;
}
.copy-button:hover {
color: var(--text-primary);
}
.copy-button svg {
flex-shrink: 0;
position: relative;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,104 @@
<script is:inline>
// Favicon theme switcher for system theme-based favicon updates, using external SVG file
class FaviconThemeSwitcher {
constructor() {
this.faviconLink =
document.querySelector('link[rel="icon"]') ||
document.querySelector('link[rel="shortcut icon"]') ||
document.querySelector('link[rel="apple-touch-icon"]')
if (!this.faviconLink) {
console.warn('Favicon link not found, skipping theme switcher')
return
}
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
this.svgUrl = '/favicon.svg'
this.currentColor = null
this.svgContent = null
this.mediaQuery.addEventListener('change', () => this.updateFavicon())
this.init()
}
async init() {
if (!this.svgContent) {
try {
const res = await fetch(this.svgUrl)
this.svgContent = await res.text()
} catch (e) {
console.warn('Failed to fetch favicon.svg:', e)
return
}
}
this.updateFavicon()
}
updateFavicon() {
const color = this.mediaQuery.matches ? '#ccc' : '#111'
if (this.currentColor === color) return
this.currentColor = color
this.updateFaviconColor(color)
}
updateFaviconColor(color) {
if (!this.svgContent) return
try {
const parser = new DOMParser()
const doc = parser.parseFromString(this.svgContent, 'image/svg+xml')
// Remove all <style> tags
doc.querySelectorAll('style').forEach((style) => style.remove())
// Recursively set fill attribute for all elements
function setFillRecursively(node) {
if (node.nodeType === 1) {
// Element node
node.setAttribute('fill', color)
// Remove fill from style attribute if present
if (node.hasAttribute('style')) {
let style = node.getAttribute('style')
style = style.replace(/fill\s*:\s*[^;]+;?/gi, '')
node.setAttribute('style', style)
}
for (let i = 0; i < node.childNodes.length; i++) {
setFillRecursively(node.childNodes[i])
}
}
}
setFillRecursively(doc.documentElement)
const serializer = new XMLSerializer()
const svg = serializer.serializeToString(doc)
const blob = new Blob([svg], { type: 'image/svg+xml' })
const blobUrl = URL.createObjectURL(blob)
this.faviconLink.href = blobUrl
if (this.previousBlobUrl) {
URL.revokeObjectURL(this.previousBlobUrl)
}
this.previousBlobUrl = blobUrl
} catch (e) {
console.warn('Failed to update favicon color:', e)
}
}
}
// Initialize favicon theme switcher
function init() {
try {
new FaviconThemeSwitcher()
} catch (error) {
console.warn('Failed to initialize favicon theme switcher:', error)
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}
// Re-initialize on Astro page loads
document.addEventListener('astro:page-load', init)
</script>

View File

@@ -0,0 +1,234 @@
<script>
import type { GitHubRepoData, CachedRepoData, CardElements } from '@/types'
let githubCardsObserver: IntersectionObserver | null = null
// Retrieve cached GitHub repository data from localStorage with expiration check
function getCachedData(repo: string): GitHubRepoData | null {
try {
const cacheKey = `github-repo-${repo}`
const cached = localStorage.getItem(cacheKey)
if (!cached) {
return null
}
const parsedCache: CachedRepoData = JSON.parse(cached)
const now = Date.now()
const oneHour = 60 * 60 * 1000 // 1 hour in milliseconds
// Check if cache is expired (older than 1 hour)
if (now - parsedCache.timestamp > oneHour) {
localStorage.removeItem(cacheKey)
return null
}
return parsedCache.data
} catch (error) {
console.warn('Failed to read from cache:', error)
return null
}
}
// Store GitHub repository data in localStorage with timestamp for cache expiration
function setCachedData(repo: string, data: GitHubRepoData): void {
try {
const cacheKey = `github-repo-${repo}`
const cacheData: CachedRepoData = {
data,
timestamp: Date.now()
}
localStorage.setItem(cacheKey, JSON.stringify(cacheData))
} catch (error) {
console.warn('Failed to save to cache:', error)
}
}
// Update the GitHub card UI elements with fetched repository data
function updateCardUI(el: CardElements, data: GitHubRepoData) {
const numberFormat = new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1
})
if (el.avatar && data.owner?.avatar_url) {
el.avatar.style.backgroundImage = `url(${data.owner.avatar_url})`
}
if (el.desc) {
el.desc.textContent = data.description ?? 'No description'
}
if (el.stars) {
el.stars.textContent = numberFormat.format(data.stargazers_count ?? 0)
}
if (el.forks) {
el.forks.textContent = numberFormat.format(data.forks_count ?? 0)
}
if (el.license) {
el.license.textContent = data.license?.spdx_id ?? 'No License'
}
}
// Load GitHub repository data for a card, using cache if available or fetching from API
async function loadCardData(card: HTMLElement) {
const repo = card.dataset.repo
if (!repo) {
return
}
const el = {
avatar: card.querySelector('.gc-owner-avatar') as HTMLElement,
desc: card.querySelector('.gc-repo-description') as HTMLElement,
stars: card.querySelector('.gc-stars-count') as HTMLElement,
forks: card.querySelector('.gc-forks-count') as HTMLElement,
license: card.querySelector('.gc-license-info') as HTMLElement
} as const
// Try to get cached data first
const cachedData = getCachedData(repo)
if (cachedData) {
updateCardUI(el, cachedData)
return
}
// If no cache, fetch from API
try {
const response = await fetch(`https://api.github.com/repos/${repo}`)
if (!response.ok) {
if (el.desc) {
el.desc.textContent = '⚠ Loading failed'
}
return
}
const data = await response.json()
setCachedData(repo, data)
updateCardUI(el, data)
} catch (error) {
console.error(`Failed to fetch ${repo}:`, error)
if (el.desc) {
el.desc.textContent = '⚠ Loading failed'
}
}
}
// Set up intersection observer for lazy loading GitHub cards when they enter viewport
function lazySetupGithubCards() {
githubCardsObserver?.disconnect()
const githubCards = document.getElementsByClassName('gc-container')
if (githubCards.length === 0) {
return
}
// Create an intersection observer to lazy load GitHub repo data when cards enter viewport
githubCardsObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadCardData(entry.target as HTMLElement)
githubCardsObserver?.unobserve(entry.target)
}
})
},
{ rootMargin: '200px' }
)
Array.from(githubCards).forEach((card) => githubCardsObserver?.observe(card))
}
lazySetupGithubCards()
document.addEventListener('astro:page-load', lazySetupGithubCards)
</script>
<style is:inline>
.prose .gc-container {
display: block;
border: 0.5px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem 0.75rem 1.25rem;
margin: 1.25rem 0 1.75rem 0;
text-decoration: none;
color: inherit;
transition: background 0.2s ease-out;
background: var(--astro-code-background);
}
.prose .gc-container:hover {
background: color-mix(in srgb, var(--selection) 75%, transparent);
text-decoration: none;
}
.prose .gc-title-bar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.prose .gc-owner-avatar {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background-color: var(--border);
flex-shrink: 0;
}
.prose .gc-repo-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-regular);
color: var(--text-primary);
flex-grow: 1;
}
.prose .gc-repo-title strong {
font-weight: var(--font-weight-bold);
}
.prose .gc-slash {
color: var(--text-secondary);
margin: 0 0.375rem;
}
.prose .gc-github-icon {
width: 1.5rem;
height: 1.5rem;
color: var(--text-primary);
flex-shrink: 0;
}
.prose .gc-repo-description {
font-size: var(--font-size-m);
color: var(--text-primary);
opacity: 0.6;
margin: 0 0 0.75rem 0;
line-height: 1.4;
}
.prose .gc-info-bar {
display: flex;
align-items: center;
color: var(--text-primary);
opacity: 0.6;
gap: 0.35rem;
}
.prose .gc-info-bar .gc-stars-count,
.prose .gc-info-bar .gc-forks-count,
.prose .gc-info-bar .gc-license-info {
margin-right: 0.675rem;
font-size: var(--font-size-s);
}
.prose .gc-info-icon {
color: var(--text-primary);
width: 0.875rem;
height: 0.875rem;
}
</style>

View File

@@ -0,0 +1,55 @@
<div class="gradient-mask">
<slot />
</div>
<script>
function updateMask() {
const mask = document.querySelector('.gradient-mask') as HTMLElement
const threshold = 64
const scrollY = window.scrollY
if (scrollY >= threshold) {
mask.style.opacity = '1'
} else {
mask.style.opacity = '0'
}
}
document.addEventListener('DOMContentLoaded', () => {
updateMask()
window.addEventListener('scroll', updateMask)
})
</script>
<style>
.gradient-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 6rem;
z-index: 99;
pointer-events: none;
background: linear-gradient(to bottom, var(--bg) 0%, transparent 100%);
mask-image: linear-gradient(
to bottom,
black 0%,
rgba(0, 0, 0, 0.8) 20%,
rgba(0, 0, 0, 0.6) 40%,
rgba(0, 0, 0, 0.4) 60%,
rgba(0, 0, 0, 0.2) 80%,
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
black 0%,
rgba(0, 0, 0, 0.8) 20%,
rgba(0, 0, 0, 0.6) 40%,
rgba(0, 0, 0, 0.4) 60%,
rgba(0, 0, 0, 0.2) 80%,
transparent 100%
);
opacity: 0;
transition: opacity 0.3s ease;
}
</style>

View File

@@ -0,0 +1,20 @@
<script is:inline>
// Global handler: Automatically remove the img-placeholder class after images are loaded
function removeImgPlaceholder() {
document.querySelectorAll('img.img-placeholder').forEach(function (img) {
if (img.complete) {
img.classList.remove('img-placeholder')
} else {
img.addEventListener('load', function () {
img.classList.remove('img-placeholder')
})
}
})
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', removeImgPlaceholder)
} else {
removeImgPlaceholder()
}
document.addEventListener('astro:page-load', removeImgPlaceholder)
</script>

View File

@@ -0,0 +1,148 @@
<div id="image-viewer" class="image-viewer">
<img id="image-viewer-img" src="" alt="" />
</div>
<script>
function initImageViewer() {
const viewer = document.getElementById('image-viewer')
const viewerImg = document.getElementById('image-viewer-img') as HTMLImageElement
if (!viewer || !viewerImg || viewerImg.tagName !== 'IMG') return
// Display image in fullscreen viewer overlay
function showImage(src: string, alt?: string) {
if (viewerImg && src) {
viewerImg.src = src
viewerImg.alt = alt || ''
}
// Show visibility first
viewer!.style.visibility = 'visible'
void viewer!.offsetWidth
viewer!.classList.add('active')
document.body.classList.add('image-viewer-open')
document.body.style.overflow = 'hidden'
document.body.dataset.scrollY = window.scrollY.toString()
viewer!.style.cursor = 'auto'
setTimeout(() => {
viewer!.style.cursor = ''
}, 10)
}
// Hide the image viewer and restore page scroll
function hideImage() {
viewer!.classList.remove('active')
document.body.classList.remove('image-viewer-open')
document.body.style.overflow = ''
}
// Hide and clear image after transition ends
viewer!.addEventListener('transitionend', (e) => {
if (e.propertyName === 'opacity' && !viewer!.classList.contains('active')) {
viewer!.style.visibility = 'hidden'
if (viewerImg) {
viewerImg.src = ''
viewerImg.alt = ''
}
viewer!.style.cursor = 'auto'
setTimeout(() => {
viewer!.style.cursor = ''
}, 10)
}
})
// Bind click events to images with data-preview="true" attribute
function bindImageClickEvents() {
const previewImages = document.querySelectorAll('img[data-preview="true"]')
previewImages.forEach((img) => {
const imgElement = img as HTMLImageElement
imgElement.style.cursor = 'zoom-in'
imgElement.addEventListener('click', (e) => {
e.preventDefault()
const target = e.target as HTMLImageElement
showImage(target.src, target.alt || '')
})
})
}
viewer?.addEventListener('click', hideImage)
// Prevent touch scroll in image viewer
viewer?.addEventListener(
'touchmove',
(e) => {
if (viewer.classList.contains('active')) {
e.preventDefault()
}
},
{ passive: false }
)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && viewer.classList.contains('active')) {
hideImage()
}
})
bindImageClickEvents()
const observer = new MutationObserver(() => {
bindImageClickEvents()
})
observer.observe(document.body, {
childList: true,
subtree: true
})
// Hide initially
viewer!.style.visibility = 'hidden'
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initImageViewer)
} else {
initImageViewer()
}
document.addEventListener('astro:page-load', initImageViewer)
</script>
<style>
.image-viewer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease-in-out;
background: color-mix(in srgb, var(--bg) 90%, transparent);
cursor: zoom-out;
}
.image-viewer.active {
opacity: 1;
}
.image-viewer img {
min-width: 45rem;
max-width: 60vw;
max-height: 80vh;
object-fit: contain;
cursor: zoom-out;
}
@media (max-width: 768px) {
.image-viewer img {
min-width: 100vw;
}
}
body.image-viewer-open {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,311 @@
<script>
import type { LinkCardMetadata } from '@/types'
let linkCardsObserver: IntersectionObserver | null = null
const metadataCache = new Map<string, LinkCardMetadata>()
// Fetch metadata from URL using our own proxy API with caching
async function fetchLinkMetadata(url: string): Promise<LinkCardMetadata | null> {
// Check cache first
if (metadataCache.has(url)) {
return metadataCache.get(url)!
}
try {
// Use our own proxy API
const proxyUrl = `/api/proxy?url=${encodeURIComponent(url)}`
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 3000) // 3 second timeout
const response = await fetch(proxyUrl, {
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error('Failed to fetch metadata')
}
const html = await response.text()
// Parse HTML to extract metadata
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const title =
doc.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
doc.querySelector('meta[name="twitter:title"]')?.getAttribute('content') ||
doc.querySelector('title')?.textContent ||
''
const description =
doc.querySelector('meta[property="og:description"]')?.getAttribute('content') ||
doc.querySelector('meta[name="twitter:description"]')?.getAttribute('content') ||
doc.querySelector('meta[name="description"]')?.getAttribute('content') ||
''
const image =
doc.querySelector('meta[property="og:image"]')?.getAttribute('content') ||
doc.querySelector('meta[name="twitter:image"]')?.getAttribute('content') ||
''
const imageAlt =
doc.querySelector('meta[property="og:image:alt"]')?.getAttribute('content') || title || ''
const metadata = {
title: title?.trim() || '',
description: description?.trim() || '',
image: image?.trim() || '',
imageAlt: imageAlt?.trim() || ''
}
// Cache the result
metadataCache.set(url, metadata)
return metadata
} catch {
return null
}
}
// Update link card with fetched metadata
async function updateLinkCard(el: HTMLElement) {
const url = el.dataset.url
if (!url) {
return
}
// Add loading state
el.classList.add('loading')
try {
// Fetch and update metadata
const metadata = await fetchLinkMetadata(url)
if (!metadata) {
return
}
// Update title
const titleElement = el.querySelector('.link-card-title') as HTMLElement
if (titleElement) {
if (metadata.title && metadata.title.trim()) {
titleElement.textContent = metadata.title
titleElement.style.display = 'block'
} else {
// Hide title element if empty
titleElement.style.display = 'none'
}
}
// Update description
const descElement = el.querySelector('.link-card-description') as HTMLElement
if (descElement) {
if (metadata.description && metadata.description.trim()) {
descElement.textContent = metadata.description
descElement.style.display = 'block'
} else {
// Hide description element if empty
descElement.style.display = 'none'
descElement.textContent = ''
}
}
// Update image
const imageContainer = el.querySelector('.link-card-image') as HTMLElement
const imageElement = el.querySelector('.link-card-image img') as HTMLImageElement
if (imageContainer && imageElement && metadata.image) {
imageElement.src = metadata.image
imageElement.alt = metadata.imageAlt
imageContainer.style.display = 'block'
}
} finally {
// Remove loading state
el.classList.remove('loading')
}
}
// Set up intersection observer for link cards
function setupLinkCards() {
linkCardsObserver?.disconnect()
const linkCards = document.getElementsByClassName('link-card')
if (linkCards.length === 0) {
return
}
// Create an intersection observer to process link cards when they enter viewport
linkCardsObserver = new IntersectionObserver(
(entries) => {
// Process all intersecting cards in parallel
const intersectingCards = entries
.filter((entry) => entry.isIntersecting)
.map((entry) => entry.target as HTMLElement)
if (intersectingCards.length > 0) {
// Update domain names immediately for better perceived performance
intersectingCards.forEach((card) => {
const url = card.dataset.url
if (url) {
try {
const domain = new URL(url).hostname.replace('www.', '')
const urlElement = card.querySelector('.link-card-url')
if (urlElement) {
urlElement.textContent = domain
}
} catch {
const urlElement = card.querySelector('.link-card-url')
if (urlElement) {
urlElement.textContent = 'invalid-url'
}
}
}
})
// Fetch metadata in parallel
Promise.allSettled(intersectingCards.map((card) => updateLinkCard(card))).then(() => {
// Unobserve all processed cards
intersectingCards.forEach((card) => linkCardsObserver?.unobserve(card))
})
}
},
{ rootMargin: '200px' }
)
Array.from(linkCards).forEach((card) => linkCardsObserver?.observe(card))
}
setupLinkCards()
document.addEventListener('astro:page-load', setupLinkCards)
</script>
<style is:inline>
.prose .link-card {
display: block;
border: 0.5px solid var(--border);
border-radius: 10px;
overflow: hidden;
text-decoration: none;
color: inherit;
background: var(--astro-code-background);
margin: 1.25rem 0 1.75rem 0;
transition: background 0.2s ease-out;
}
.prose .link-card:hover {
background: color-mix(in srgb, var(--selection) 75%, transparent);
text-decoration: none;
}
.prose .link-card.loading {
pointer-events: none;
}
.prose .link-card-content {
padding: 1rem 1.25rem 0.75rem 1.25rem;
}
.prose .link-card-image-outer {
padding: 0 0.5rem 0.5rem 0.5rem;
}
/* Set container size/padding when image hidden */
.prose .link-card-image-outer:has(.link-card-image[style*='display: none']) {
padding: 0 0.25rem 0.25rem 0.25rem;
}
.prose .link-card-image {
width: 100%;
aspect-ratio: 16 / 9;
/* Fallback for browsers that don't support aspect-ratio */
height: 0;
padding-bottom: 56.25%; /* 16:9 aspect ratio (9/16 = 0.5625) */
overflow: hidden;
background: var(--border);
position: relative;
margin: 0;
padding: 0;
border-radius: 8px;
}
/* Modern browsers with aspect-ratio support */
@supports (aspect-ratio: 16 / 9) {
.prose .link-card-image {
height: auto;
padding-bottom: 0;
}
}
.prose .link-card-image img {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
margin: 0;
padding: 0;
}
.prose .link-card-title {
font-size: var(--font-size-m);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin: 0 0 0.375rem 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 1rem;
}
.prose .link-card .link-card-description {
font-size: var(--font-size-m);
color: var(--text-primary);
opacity: 0.6;
margin: 0 0 0.1875rem 0;
display: -webkit-box !important;
-webkit-line-clamp: 2 !important;
-webkit-box-orient: vertical !important;
overflow: hidden !important;
word-wrap: break-word !important;
/* Fallback for older browsers */
max-height: calc(1.4em * 2) !important;
}
/* Hide description when display is set to none */
.prose .link-card .link-card-description[style*='display: none'] {
display: none !important;
margin: 0 !important;
padding: 0 !important;
height: 0 !important;
overflow: hidden !important;
}
/* Modern browsers with line-clamp support */
@supports (-webkit-line-clamp: 2) {
.prose .link-card .link-card-description {
max-height: none !important;
}
}
.prose .link-card-url {
font-size: var(--font-size-s);
color: var(--text-secondary);
letter-spacing: 0.015em;
margin: 0;
}
.prose .link-card:not(:has(.link-card-image[style*='display: block'])) {
padding: 1rem 1.25rem 0.75rem 1.25rem;
}
.prose .link-card:not(:has(.link-card-image[style*='display: block'])) .link-card-content {
padding: 0;
}
.prose .link-card:has(.link-card-image[style*='display: block']) .link-card-content {
padding: 1rem 1.25rem 0.75rem 1.25rem;
}
</style>

View File

@@ -0,0 +1,389 @@
---
import { themeConfig } from '@/config'
import type { TOCProps } from '@/types'
const { toc = [] }: TOCProps = Astro.props
---
<div class="toc-container" id="toc">
<nav class="toc-nav">
<ul class="toc-list" id="toc-list">
<!-- Back to top link -->
<li class="toc-item toc-level-0">
<a href="#" class="toc-link toc-title" title="Back to top" data-text="Back to top">
Back to top
</a>
</li>
<!-- TOC items -->
{
toc.map((item) => (
<li class={`toc-item toc-level-${item.level}`}>
<a href={`#${item.id}`} class="toc-link" title={item.text} data-text={item.text}>
{item.text}
</a>
</li>
))
}
</ul>
</nav>
</div>
<script
is:inline
define:vars={{
contentWidth: themeConfig.general.contentWidth,
centeredLayout: themeConfig.general.centeredLayout,
toc: themeConfig.post.toc
}}
>
;(function () {
// Core state
const state = {
container: null,
links: null,
headings: null,
titleLink: null,
headingMap: new Map(),
positions: [],
scrollTimeout: null,
hasContent: false
}
// Initialize DOM elements
function initElements() {
state.container = document.querySelector('.toc-container')
if (!state.container) return false
state.links = document.querySelectorAll('.toc-link')
state.headings = document.querySelectorAll('h1, h2, h3')
state.titleLink = document.querySelector('.toc-link.toc-title')
// Build heading map
state.headingMap.clear()
state.links.forEach((link) => {
const href = link.getAttribute('href')
if (href?.startsWith('#')) {
state.headingMap.set(href.substring(1), link)
}
})
return true
}
// Check if content exists
function checkContent() {
if (!state.container || !state.links) return
const tocItems = Array.from(state.links).filter(
(link) => !link.classList.contains('toc-title')
)
state.hasContent = tocItems.length > 0
if (!state.hasContent) {
state.container.style.display = 'none'
}
}
// Cache heading positions
function cachePositions() {
if (!state.headings?.length) return
const scrollTop = window.pageYOffset
state.positions = Array.from(state.headings)
.filter((_, index) => !(index === 0 && state.headings[0].tagName === 'H1'))
.map((heading) => ({
id: heading.id,
offsetTop: heading.getBoundingClientRect().top + scrollTop
}))
}
// Adjust TOC position
function adjustPosition() {
if (!state.container || !centeredLayout || !state.hasContent) {
if (state.container) state.container.style.display = 'none'
return
}
const pageWidth = window.innerWidth
const contentWidthValue = Math.max(parseFloat(contentWidth), 25)
const margin = (pageWidth - contentWidthValue * 16) / 2
const baseMinSpace = 11 * 16 // Base minimum space needed
const minSpace = toc ? baseMinSpace + 52 : baseMinSpace + 12
if (margin >= minSpace) {
state.container.style.display = 'block'
state.container.classList.add('fixed-position')
const leftPosition = toc ? margin - 176 - 40 : margin - 176
state.container.style.left = `${leftPosition}px`
} else {
state.container.style.display = 'none'
state.container.classList.remove('fixed-position')
state.container.style.left = ''
}
}
// Handle click events
function handleClick(e) {
const link = e.target.closest('.toc-link')
if (!link) return
e.preventDefault()
if (link.classList.contains('toc-title')) {
window.scrollTo({ top: 0, behavior: 'smooth' })
history.pushState(null, null, '#')
} else {
const href = link.getAttribute('href')
if (href?.startsWith('#')) {
const target = document.getElementById(href.substring(1))
if (target) {
const rect = target.getBoundingClientRect()
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const offset = rect.top + scrollTop - 96
window.scrollTo({ top: offset, behavior: 'smooth' })
history.pushState(null, null, href)
}
}
}
}
// Update active state
function updateActive() {
if (!state.links?.length || !state.positions.length) return
const scrollTop = window.pageYOffset + 100
let currentActive = null
// Find current active heading
for (let i = state.positions.length - 1; i >= 0; i--) {
if (scrollTop >= state.positions[i].offsetTop) {
currentActive = state.positions[i].id
break
}
}
// Update active state
state.links.forEach((link) => link.classList.remove('active'))
if (currentActive && state.headingMap.has(currentActive)) {
state.headingMap.get(currentActive).classList.add('active')
} else if (state.titleLink) {
state.titleLink.classList.add('active')
}
}
// Initialize
function init(retryCount = 0) {
const maxRetries = 5
if (initElements()) {
checkContent()
adjustPosition()
cachePositions()
if (state.container) {
state.container.removeEventListener('click', handleClick)
state.container.addEventListener('click', handleClick)
}
updateActive()
} else if (retryCount < maxRetries) {
setTimeout(() => init(retryCount + 1), 100)
}
}
// Event handlers
function handleScroll() {
if (state.scrollTimeout) {
cancelAnimationFrame(state.scrollTimeout)
}
state.scrollTimeout = requestAnimationFrame(updateActive)
}
function handleResize() {
adjustPosition()
requestAnimationFrame(cachePositions)
}
// Cleanup
function cleanup() {
if (state.scrollTimeout) {
cancelAnimationFrame(state.scrollTimeout)
state.scrollTimeout = null
}
Object.assign(state, {
container: null,
links: null,
headings: null,
titleLink: null,
headingMap: new Map(),
positions: [],
hasContent: false
})
}
// Event listeners
document.addEventListener('astro:page-load', () => {
cleanup()
init()
})
document.addEventListener('astro:after-swap', () => {
cleanup()
init()
})
// Fallback for when Astro transitions are disabled
document.addEventListener('DOMContentLoaded', () => {
if (!state.container || !state.hasContent) {
init()
}
})
window.addEventListener('resize', handleResize)
window.addEventListener('scroll', handleScroll)
})()
</script>
<style is:inline>
.toc-container {
width: 12rem;
position: relative;
left: -0.175em;
opacity: 0;
transition: opacity 0.2s ease-out;
display: none;
}
.toc-container.fixed-position {
opacity: 1;
position: fixed;
top: 12rem;
margin-top: 0;
padding-left: 1rem;
z-index: 10;
left: auto;
}
.toc-nav {
font-family: var(--sans);
}
.toc-list,
.toc-list li,
.toc-item {
list-style: none;
margin: 0;
padding: 0;
}
.prose .toc-container .toc-list {
margin-left: 0 !important;
padding-left: 0 !important;
}
.prose .toc-container .toc-list li {
margin: 0 !important;
padding: 0 !important;
}
.toc-item::before,
.toc-item::marker {
display: none;
}
.toc-link {
display: block;
color: transparent;
text-decoration: none;
position: relative;
padding-left: 0;
height: 1.125rem;
width: 100%;
min-height: 1rem;
font-size: 0;
line-height: 1.125rem;
text-indent: 2rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition:
color 0.2s ease-out,
font-size 0.2s ease-out,
text-indent 0.2s ease-out;
cursor: pointer;
}
.toc-link::after {
content: attr(data-text);
position: absolute;
left: -0.5rem;
top: 0;
font-family: var(--sans);
font-size: var(--font-size-s);
letter-spacing: var(--spacing-m);
line-height: 1.125rem;
color: var(--text-primary);
opacity: 0;
transition:
opacity 0.2s ease-out,
left 0.2s ease-out;
pointer-events: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
}
.toc-link:hover::after {
opacity: 1;
left: -0.75rem;
}
.toc-level-0 .toc-link:hover::after {
opacity: 0;
}
.toc-level-1 .toc-link:hover::before,
.toc-level-2 .toc-link:hover::before,
.toc-level-3 .toc-link:hover::before {
width: 0.75rem;
transition: width 0.1s ease-out;
}
.toc-link.active {
color: var(--text-primary);
}
/* Horizontal line indicators */
.toc-level-0 .toc-link::before,
.toc-level-1 .toc-link::before,
.toc-level-2 .toc-link::before,
.toc-level-3 .toc-link::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 2.5rem;
height: 1px;
background-color: var(--text-tertiary);
transform: translateY(-50%);
opacity: 0.4;
transition: all 0.1s ease-out;
}
.toc-link:hover::before,
.toc-link.active::before {
opacity: 0.8;
background-color: var(--text-primary);
}
/* Hide on mobile */
@media (max-width: 768px) {
.toc-container {
display: none !important;
}
}
</style>

View File

@@ -0,0 +1,122 @@
<script is:inline>
// Global Theme Manager
;(function () {
// Prevent duplicate initialization
if (window.ThemeManager && window.ThemeManager.initialized) {
return
}
window.ThemeManager = {
STORAGE_KEY: 'chiri-theme',
initialized: false,
getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
},
getStoredTheme() {
try {
return localStorage.getItem(this.STORAGE_KEY)
} catch {
return null
}
},
setStoredTheme(theme) {
try {
if (theme === 'system') {
localStorage.removeItem(this.STORAGE_KEY)
} else {
localStorage.setItem(this.STORAGE_KEY, theme)
}
} catch (e) {
console.warn('Failed to store theme preference:', e)
}
},
getEffectiveTheme() {
const stored = this.getStoredTheme()
return stored || this.getSystemTheme()
},
isUsingSystemTheme() {
return this.getStoredTheme() === null
},
applyTheme(theme) {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme)
// Dispatch event for other components
document.dispatchEvent(
new CustomEvent('themechange', {
detail: {
theme,
isUserChoice: !this.isUsingSystemTheme(),
isSystemTheme: this.isUsingSystemTheme()
}
})
)
},
toggle() {
const currentTheme = this.getEffectiveTheme()
// Simply toggle between light and dark
const newTheme = currentTheme === 'dark' ? 'light' : 'dark'
this.setStoredTheme(newTheme)
this.applyTheme(newTheme)
},
init() {
if (this.initialized) return
// Set initial theme (maintain current theme when refreshing page)
this.applyTheme(this.getEffectiveTheme())
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
const newSystemTheme = e.matches ? 'dark' : 'light'
// Always follow system theme changes and update stored theme preference
this.setStoredTheme(newSystemTheme)
this.applyTheme(newSystemTheme)
})
this.initialized = true
}
}
// Initialize theme manager
window.ThemeManager.init()
// Listen for Astro page transition events, but delay execution to avoid conflicts with transition animations
document.addEventListener('astro:page-load', () => {
if (window.ThemeManager) {
// Use requestAnimationFrame to ensure execution in the next frame, avoiding conflicts with transition animations
requestAnimationFrame(() => {
const currentTheme = window.ThemeManager.getEffectiveTheme()
const appliedTheme = document.documentElement.classList.contains('dark')
? 'dark'
: 'light'
// Only reapply theme when there's a mismatch to avoid unnecessary flickering
if (currentTheme !== appliedTheme) {
window.ThemeManager.applyTheme(currentTheme)
}
})
}
})
// Listen for page transition start event to ensure theme is ready before transition
document.addEventListener('astro:before-preparation', () => {
if (window.ThemeManager) {
const theme = window.ThemeManager.getEffectiveTheme()
// Ensure theme class is applied before transition starts
if (!document.documentElement.classList.contains(theme)) {
document.documentElement.classList.add(theme)
}
}
})
})()
</script>

View File

@@ -0,0 +1,68 @@
---
import { themeConfig } from '@/config'
---
{
themeConfig.general.themeToggle && (
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
<div class="theme-icon hollow-circle" />
<div class="theme-icon solid-circle" />
</button>
)
}
<script is:inline>
function bindThemeToggle() {
const themeToggle = document.getElementById('theme-toggle')
if (themeToggle && window.ThemeManager) {
// Remove existing event listeners to prevent duplicates
const newToggle = themeToggle.cloneNode(true)
themeToggle.parentNode.replaceChild(newToggle, themeToggle)
newToggle.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
window.ThemeManager.toggle()
})
}
}
// Bind on initial load
window.addEventListener('DOMContentLoaded', bindThemeToggle)
// Bind on Astro page transitions
document.addEventListener('astro:page-load', bindThemeToggle)
</script>
<style>
.theme-toggle {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
height: 1.5rem;
gap: 0.325rem;
color: var(--text-primary);
transition: opacity 0.2s ease;
padding: 0;
position: relative;
}
.theme-icon {
width: 0.5625rem;
height: 0.5625rem;
transition: all 0.2s ease;
}
.hollow-circle {
border: none;
border-radius: 50%;
box-shadow: inset 0 0 0 1.5px var(--text-primary);
}
.solid-circle {
background-color: var(--text-primary);
border-radius: 50%;
}
</style>

View File

@@ -0,0 +1,43 @@
<script>
function loadXCards() {
const xCards = document.querySelectorAll('.twitter-tweet')
if (xCards.length === 0) return
if (document.querySelector('script[src*="platform.twitter.com/widgets.js"]')) {
return
}
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
xCards.forEach((element) => {
element.setAttribute('data-theme', isDark ? 'dark' : 'light')
})
const script = document.createElement('script')
script.src = 'https://platform.twitter.com/widgets.js'
script.async = true
document.head.appendChild(script)
}
document.addEventListener('DOMContentLoaded', loadXCards)
document.addEventListener('astro:page-load', loadXCards)
</script>
<style is:inline>
.prose .x-card {
width: 100%;
margin: 1em auto;
text-align: center;
}
.prose .x-card > * {
display: inline-block;
max-width: 100%;
margin: 0 auto;
}
.prose .x-card iframe {
max-width: 100%;
width: auto;
}
</style>

View File

@@ -0,0 +1,160 @@
<div class="variable-blur-mask">
<div class="blur-bottom">
<!-- Layer 1 -->
<div
class="blur-layer layer-1"
style={{
maskImage:
'linear-gradient(to top, rgba(0,0,0,0) 0%, rgba(255,255,255,1) 12.5%, rgba(255,255,255,1) 25%, rgba(0,0,0,0) 37.5%)',
WebkitMaskImage:
'linear-gradient(to top, rgba(0,0,0,0) 0%, rgba(255,255,255,1) 12.5%, rgba(255,255,255,1) 25%, rgba(0,0,0,0) 37.5%)'
}}
>
</div>
<!-- Layer 2 -->
<div
class="blur-layer layer-2"
style={{
maskImage:
'linear-gradient(to top, rgba(0,0,0,0) 12.5%, rgba(255,255,255,1) 25%, rgba(255,255,255,1) 37.5%, rgba(0,0,0,0) 50%)',
WebkitMaskImage:
'linear-gradient(to top, rgba(0,0,0,0) 12.5%, rgba(255,255,255,1) 25%, rgba(255,255,255,1) 37.5%, rgba(0,0,0,0) 50%)'
}}
>
</div>
<!-- Layer 3 -->
<div
class="blur-layer layer-3"
style={{
maskImage:
'linear-gradient(to top, rgba(0,0,0,0) 25%, rgba(255,255,255,1) 37.5%, rgba(255,255,255,1) 50%, rgba(0,0,0,0) 62.5%)',
WebkitMaskImage:
'linear-gradient(to top, rgba(0,0,0,0) 25%, rgba(255,255,255,1) 37.5%, rgba(255,255,255,1) 50%, rgba(0,0,0,0) 62.5%)'
}}
>
</div>
<!-- Layer 4 -->
<div
class="blur-layer layer-4"
style={{
maskImage:
'linear-gradient(to top, rgba(0,0,0,0) 37.5%, rgba(255,255,255,1) 50%, rgba(255,255,255,1) 62.5%, rgba(0,0,0,0) 75%)',
WebkitMaskImage:
'linear-gradient(to top, rgba(0,0,0,0) 37.5%, rgba(255,255,255,1) 50%, rgba(255,255,255,1) 62.5%, rgba(0,0,0,0) 75%)'
}}
>
</div>
<!-- Layer 5 -->
<div
class="blur-layer layer-5"
style={{
maskImage:
'linear-gradient(to top, rgba(0,0,0,0) 50%, rgba(255,255,255,1) 62.5%, rgba(255,255,255,1) 75%, rgba(0,0,0,0) 87.5%)',
WebkitMaskImage:
'linear-gradient(to top, rgba(0,0,0,0) 50%, rgba(255,255,255,1) 62.5%, rgba(255,255,255,1) 75%, rgba(0,0,0,0) 87.5%)'
}}
>
</div>
<!-- Layer 6 -->
<div
class="blur-layer layer-6"
style={{
maskImage:
'linear-gradient(to top, rgba(0,0,0,0) 62.5%, rgba(255,255,255,1) 75%, rgba(255,255,255,1) 87.5%, rgba(0,0,0,0) 100%)',
WebkitMaskImage:
'linear-gradient(to top, rgba(0,0,0,0) 62.5%, rgba(255,255,255,1) 75%, rgba(255,255,255,1) 87.5%, rgba(0,0,0,0) 100%)'
}}
>
</div>
<!-- Layer 7 -->
<div
class="blur-layer layer-7"
style={{
maskImage:
'linear-gradient(to top, rgba(0,0,0,0) 75%, rgba(255,255,255,1) 87.5%, rgba(255,255,255,1) 100%)',
WebkitMaskImage:
'linear-gradient(to top, rgba(0,0,0,0) 75%, rgba(255,255,255,1) 87.5%, rgba(255,255,255,1) 100%)'
}}
>
</div>
<!-- Layer 8 -->
<div
class="blur-layer layer-8"
style={{
maskImage: 'linear-gradient(to top, rgba(0,0,0,0) 87.5%, rgba(255,255,255,1) 100%)',
WebkitMaskImage: 'linear-gradient(to top, rgba(0,0,0,0) 87.5%, rgba(255,255,255,1) 100%)'
}}
>
</div>
</div>
<slot />
</div>
<style>
.variable-blur-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4rem;
z-index: 99;
pointer-events: none;
}
.blur-bottom {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.blur-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.layer-1 {
z-index: 1;
backdrop-filter: blur(0.5px);
}
.layer-2 {
z-index: 2;
backdrop-filter: blur(1px);
}
.layer-3 {
z-index: 3;
backdrop-filter: blur(2px);
}
.layer-4 {
z-index: 4;
backdrop-filter: blur(4px);
}
.layer-5 {
z-index: 5;
backdrop-filter: blur(8px);
}
.layer-6 {
z-index: 6;
backdrop-filter: blur(16px);
}
.layer-7 {
z-index: 7;
backdrop-filter: blur(32px);
}
.layer-8 {
z-index: 8;
backdrop-filter: blur(64px);
}
</style>

View File

@@ -0,0 +1,25 @@
---
import { getEntry } from 'astro:content'
import { render } from 'astro:content'
const aboutEntry = await getEntry('about', 'about')
// Check if there is actual content (excluding comments)
const hasContent = aboutEntry?.body
? aboutEntry.body.replace(/<!--[\s\S]*?-->/g, '').trim().length > 0
: false
const { Content } = hasContent && aboutEntry ? await render(aboutEntry) : { Content: null }
---
{
hasContent && Content && (
<div class="about prose">
<Content />
</div>
)
}
<style>
.about:not(:empty) {
margin-bottom: 1.25rem;
}
</style>

View File

@@ -0,0 +1,33 @@
<script>
function bindFootnoteEvents() {
const footnoteLinks = document.querySelectorAll('[data-footnote-ref], [data-footnote-backref]')
footnoteLinks.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault()
const href = link.getAttribute('href')
if (!href) return
const target = document.querySelector(href)
if (!target) return
// Use fixed offset of 128px
const offset = 128
const targetPosition = target.getBoundingClientRect().top + window.scrollY - offset
window.scrollTo({
top: targetPosition
})
})
})
}
document.addEventListener('astro:page-load', () => {
bindFootnoteEvents()
})
document.addEventListener('DOMContentLoaded', () => {
bindFootnoteEvents()
})
</script>

View File

@@ -0,0 +1,31 @@
---
import { themeConfig } from '@/config'
import { formatDate } from '@/utils/date'
import type { FormattedDateProps } from '@/types'
const {
date,
format,
context = 'default'
} = Astro.props as FormattedDateProps & { context?: 'list' | 'post' | 'default' }
---
<time
datetime={date.toISOString()}
class={!themeConfig.date.dateOnRight &&
(themeConfig.date.dateFormat === 'MONTH DAY YYYY' ||
themeConfig.date.dateFormat === 'DAY MONTH YYYY') &&
context === 'list'
? 'date-left'
: ''}
>
<Fragment set:html={formatDate(date, format)} />
</time>
<style>
.date-left {
display: inline-block;
min-width: 86px;
text-align: right;
}
</style>

View File

@@ -0,0 +1,145 @@
---
import FormattedDate from '@/components/widgets/FormattedDate.astro'
import type { PostListProps } from '@/types'
import { themeConfig } from '@/config'
const { posts } = Astro.props as PostListProps
---
<ul>
{
posts.map((post) => (
<li>
<a href={`/${post.id}/`}>
<div class={`post-item ${!themeConfig.date.dateOnRight ? 'date-left' : ''}`}>
{!themeConfig.date.dateOnRight && (
<p class="date font-features">
<FormattedDate date={post.data.pubDate} context="list" />
</p>
)}
<p class="title">{post.data.title}</p>
{themeConfig.date.dateOnRight && (
<div
class={themeConfig.general.postListDottedDivider ? 'dotted-divider' : 'divider'}
/>
)}
{themeConfig.date.dateOnRight && (
<p class="date font-features">
<FormattedDate date={post.data.pubDate} context="list" />
</p>
)}
</div>
</a>
</li>
))
}
</ul>
<div class="placeholder"></div>
<style>
ul {
padding: 0;
margin: 0;
list-style-type: none;
display: flex;
flex-direction: column;
gap: 0;
}
a {
color: var(--text-primary);
display: block;
text-decoration: none;
transition: opacity 0.15s ease-out;
}
@media (hover: hover) and (pointer: fine) {
ul:hover a {
opacity: 0.4;
}
ul:hover a:hover {
opacity: 1;
}
ul:hover a:hover .divider {
background-color: var(--text-tertiary);
opacity: 0.75;
}
ul:hover a:hover .dotted-divider {
color: var(--text-secondary);
}
ul:hover a:hover .date {
color: var(--text-secondary);
opacity: 1;
}
}
.post-item {
height: 2.75rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.post-item.date-left {
justify-content: flex-start;
}
.post-item.date-left .title {
flex: 1 1 auto;
min-width: 0;
}
.post-item.date-left .date {
margin-right: 0.75rem;
}
.title {
margin: 0;
flex-shrink: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.date {
margin: 0;
color: var(--text-secondary);
opacity: 0.75;
letter-spacing: var(--spacing-s);
flex-shrink: 0;
white-space: nowrap;
}
.divider {
flex: 1 1 auto;
min-width: 3rem;
margin: 0 0.25rem;
height: 0.5px;
background-color: var(--border);
}
.dotted-divider {
flex: 1 1 3rem;
min-width: 3rem;
max-width: 100%;
text-align: end;
letter-spacing: 5px;
height: 1.675rem;
overflow: hidden;
color: var(--text-tertiary);
opacity: 0.75;
}
.dotted-divider::after {
content: '·····························································································································································';
pointer-events: none;
}
.placeholder {
height: 3rem;
}
</style>

37
src/config.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { ThemeConfig } from './types'
export const themeConfig: ThemeConfig = {
// SITE INFO ///////////////////////////////////////////////////////////////////////////////////////////
site: {
website: 'https://astro-chiri.netlify.app/', // Site domain
title: 'CHIRI', // Site title
author: '3ASH', // Author name
description: 'Minimal blog built by Astro', // Site description
language: 'en-US' // Default language
},
// GENERAL SETTINGS ////////////////////////////////////////////////////////////////////////////////////
general: {
contentWidth: '35rem', // Content area width
centeredLayout: true, // Use centered layout (false for left-aligned)
themeToggle: false, // Show theme toggle button (uses system theme by default)
postListDottedDivider: false, // Show dotted divider in post list
footer: true, // Show footer
fadeAnimation: true // Enable fade animations
},
// DATE SETTINGS ///////////////////////////////////////////////////////////////////////////////////////
date: {
dateFormat: 'YYYY-MM-DD', // Date format: YYYY-MM-DD, MM-DD-YYYY, DD-MM-YYYY, MONTH DAY YYYY, DAY MONTH YYYY
dateSeparator: '.', // Date separator: . - / (except for MONTH DAY YYYY and DAY MONTH YYYY)
dateOnRight: true // Date position in post list (true for right, false for left)
},
// POST SETTINGS ///////////////////////////////////////////////////////////////////////////////////////
post: {
readingTime: false, // Show reading time in posts
toc: true, // Show the table of contents (when there is enough page width)
imageViewer: true, // Enable image viewer
copyCode: true // Enable copy button in code blocks
}
}

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

@@ -0,0 +1,24 @@
import { glob } from 'astro/loaders'
import { defineCollection, z } from 'astro:content'
const posts = defineCollection({
// Load Markdown and MDX files in the `src/content/posts/` directory.
loader: glob({ base: './src/content/posts', pattern: '**/*.{md,mdx}' }),
// Type-check frontmatter using a schema
schema: () =>
z.object({
title: z.string(),
// Transform string to Date object
pubDate: z.coerce.date(),
image: z.string().optional()
})
})
const about = defineCollection({
// Load Markdown files in the `src/content/about/` directory.
loader: glob({ base: './src/content/about', pattern: '**/*.md' }),
// Type-check frontmatter using a schema
schema: z.object({})
})
export const collections = { posts, about }

View File

@@ -0,0 +1,16 @@
---
title: 'About'
---
<!--
This content will be displayed at the top of the index page.
You can leave this empty if you dont want to show any content.
-->
A static blog theme based on [Astro](https://astro.build), designed for clarity and focus.
With a deliberately minimal design, this layout ensures your content takes center stage. It's built for flexibility, offering customization options that honor its clean and elegant aesthetic.
Effortlessly share your thoughts in _a calm & dustless space._
Check posts for details and view source on [GitHub](https://github.com/the3ash/astro-chiri).

View File

@@ -0,0 +1,49 @@
---
title: 'KaTeX Examples'
pubDate: '2025-05-19'
---
This theme includes built-in KaTeX support for rendering mathematical expressions in your content.
---
## Examples
- Inline math: $E = mc^2$
- Block math:
$$
\begin{equation}
\sum_{i=1}^{k+1} i = \left(\sum_{i=1}^{k} i\right) + (k+1)
\end{equation}
$$
$$
\begin{equation}
= \frac{k(k+1)}{2} + k + 1
\end{equation}
$$
$$
\begin{equation}
= \frac{k(k+1) + 2(k+1)}{2}
\end{equation}
$$
$$
\begin{equation}
= \frac{(k+1)(k+2)}{2}
\end{equation}
$$
$$
\begin{equation}
= \frac{(k+1)((k+1)+1)}{2}
\end{equation}
$$
---
## Ref
- [KaTeX Documentation](https://katex.org/docs/supported.html)

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

View File

@@ -0,0 +1,6 @@
---
title: 'Draft Example'
pubDate: '2025-07-09'
---
Start the filename with `_` to mark it as a draft and hide it from the list.

View File

@@ -0,0 +1,50 @@
---
title: 'Embedded Content'
pubDate: '2025-06-06'
---
Use these directives to embed media:
```
::link{url="https://xxxxx"}
::spotify{url="https://open.spotify.com/type/xxxxxx"}
::youtube{url="https://www.youtube.com/watch?v=xxxxxx"}
::bilibili{url="https://www.bilibili.com/video/xxxxxx"}
::github{repo="username/repo"}
::x{url="https://x.com/username/status/xxxxxx"}
```
```
🟡 When embedded content is still loading, the table of contents positioning may be inaccurate.
```
## Link Card
::link{url="https://pitchfork.com/reviews/albums/ichiko-aoba-luminescent-creatures/"}
## Spotify
::spotify{url="https://open.spotify.com/track/41Y0ch6R3jzpJOZv6nhf9Z?si=6c82dbed65ab4853"}
::spotify{url="https://open.spotify.com/album/1kBPEN3NIVwjdmIjjNk9vB?si=Lz29MvjwRnKX9y3dhxlbaQ"}
## YouTube
::youtube{url="https://www.youtube.com/embed/GlhV-OKHecI?si=KdB4rRPLAMEK-ozf"}
## BiliBili
::bilibili{url="https://www.bilibili.com/video/BV1Vm421W7pX/?vd_source=c0bc2746a6d2b23de50d26376498b2ff"}
## GitHub
::github{repo="the3ash/astro-chiri"}
## X Post
::x{url="https://x.com/DAVID_LYNCH/status/1174367510893752321"}

View File

@@ -0,0 +1,87 @@
---
title: 'Markdown Style Guide'
pubDate: '2025-06-28'
---
This theme does not define more levels of headlines. If needed, you can define them in `src/styles/post.css`.
---
## Paragraph
Here's a practical example of a paragraph in Markdown. This text demonstrates how content flows naturally in a blog post.
You can use various formatting options like **bold**, _italic_, ~~strikethrough~~, and `code` within your paragraphs.
## Blockquotes
> Don't communicate by sharing memory, share memory by communicating.<br>
> — <cite>Rob Pike[^1]</cite>
[^1]: The above quote is excerpted from Rob Pike's [talk](https://www.youtube.com/watch?v=PAAkCSZUG1c) during Gopherfest, November 18, 2015.
### Ordered List
1. First item
2. Second item
3. Third item
### Unordered List
- Item
- Subitem
- Subitem
## Task List
- [ ] First item
- [ ] Second item
- [x] Third item
## Image
To hide the caption, start it with an underscore `_` or leave the alt text empty.
![HIKARI](./_assets/hikari.jpg)
## Tables
| Style | Weight | Other |
| -------- | -------- | ------ |
| Normal | Regular | Text |
| _Italic_ | **Bold** | `Code` |
## Code Blocks
```jsx
// Button.jsx
const Button = ({ text, onClick }) => {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1)
onClick?.()
}
return (
<button className="btn" onClick={handleClick}>
{text} ({count})
</button>
)
}
```
## Other Elements — sub, sup, abbr, kbd, mark
H<sub>2</sub>O
X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>
<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.
Press <kbd>CTRL</kbd> + <kbd>ALT</kbd> + <kbd>Delete</kbd> to end the session.
Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.
---

View File

@@ -0,0 +1,77 @@
---
title: 'The TR-808 Story'
pubDate: '2025-05-10'
---
![_tr-808](./_assets/tr-808.jpg)
The Roland TR-808 Rhythm Composer, often simply called the "808," is one of the most influential electronic instruments ever created. Despite its initial commercial failure, this drum machine went on to shape entire genres of music and become a cultural icon. This is the story of how a machine designed to replace drummers ended up revolutionizing music production.
## The Birth of the 808
In the late 1970s, Roland Corporation was looking to create an affordable drum machine that could compete with the expensive Linn LM-1, which was the first drum machine to use digital samples. The company's engineers, led by **Ikutaro Kakehashi**, set out to create something different—a machine that would use **analog synthesis** to generate drum sounds rather than digital samples.
The development team faced several challenges:
1. **Cost constraints** - The machine needed to be affordable for home musicians
2. **Sound design** - Creating realistic drum sounds using only analog circuits
3. **User interface** - Making it intuitive for musicians to program rhythms
> "We wanted to create something that would make drummers obsolete, but instead we created something that made everyone want to be a drummer." — Ikutaro Kakehashi
## The "Failed" Launch
When the TR-808 was released in 1980, it was met with **disappointing sales**. The machine cost \$1,195 (approximately \$4,000 in today's dollars) and was criticized for its "unrealistic" drum sounds. Professional studios preferred the more expensive Linn LM-1, which used actual drum samples.
The 808's analog sounds were considered too electronic and artificial:
- The kick drum was too boomy and lacked the punch of real drums
- The snare had a distinctive "clap" sound that sounded nothing like a real snare
- The hi-hats were metallic and harsh
- The toms had a characteristic "boing" sound
Roland discontinued the TR-808 in 1983 after selling only about **12,000 units**, considering it a commercial failure.
## The Hip-Hop Revolution
The TR-808's fortunes changed dramatically when **hip-hop producers** discovered its unique sound. In the early 1980s, young producers in New York, particularly in the Bronx, began experimenting with the machine.
Key early adopters included **Afrika Bambaataa** who used the 808 on "Planet Rock" (1982), **Marley Marl** who pioneered the use of 808 kicks in hip-hop, **Rick Rubin** who incorporated 808 sounds in early Def Jam recordings, and **The Bomb Squad** who used 808s extensively in Public Enemy's production.
The 808's distinctive kick drum became the foundation of hip-hop's rhythmic backbone. Its deep, resonant bass sound could shake entire neighborhoods when played through powerful sound systems.
## The Miami Bass Phenomenon
In the mid-1980s, the TR-808 found another home in **Miami**, where it became the centerpiece of a new genre called **Miami Bass** or **Booty Bass**. Producers like **2 Live Crew** made the 808 kick drum the star of their tracks, **DJ Magic Mike** created entire albums built around 808 patterns, and **Luke Skyywalker** used 808s to create the signature Miami sound.
The 808's ability to produce extremely low frequencies made it perfect for the car audio culture that was emerging in Miami, where bass-heavy music became a status symbol.
## Electronic Music and Dance
The TR-808's influence extended far beyond hip-hop. In the late 1980s and early 1990s, it became essential in house music and techno. **Frankie Knuckles** and **Marshall Jefferson** used 808s in early Chicago house, while the machine's hi-hats and claps became signature sounds of house music. Its programmable sequencer allowed for complex rhythmic patterns.
In techno music, **Juan Atkins**, **Derrick May**, and **Kevin Saunderson** (the Belleville Three) incorporated 808s into Detroit techno. The machine's futuristic sounds fit perfectly with techno's robotic aesthetic, and its affordability made it accessible to bedroom producers.
## The 808 in Modern Music
Even today, the TR-808 continues to influence music production through software emulations and hardware reissues. **Roland Cloud** offers the official software version of the 808, **Native Instruments** includes 808 samples in their libraries, and **Ableton Live** features 808-inspired drum racks.
Hardware reissues include the **Roland TR-08** boutique series reissue, the **Roland TR-8S** modern drum machine with 808 sounds, and the **Behringer RD-8** affordable clone of the original.
## Cultural Impact
The TR-808 has transcended its role as a musical instrument to become a **cultural symbol**. It has influenced fashion with 808-inspired clothing and accessories, art with visual artists incorporating 808 imagery in their work, film through documentaries and movies about the machine's impact, and literature with books and articles celebrating its legacy.
> "The 808 didn't just change music—it changed culture. It gave a voice to communities that didn't have access to expensive studio equipment." — Questlove
## The Enduring Legacy
The Roland TR-808's story is a perfect example of how **failure can lead to innovation**. What was initially considered a commercial flop became one of the most important musical instruments of the 20th century.
Key lessons from the 808 story include embracing imperfection (the 808's "flaws" became its greatest strengths), understanding that accessibility matters (affordable tools can democratize music creation), recognizing that community adoption is crucial (users often find creative applications designers never imagined), and appreciating that timeless design remains relevant for decades.
The TR-808's influence continues to grow, proving that sometimes the most revolutionary innovations come from unexpected places. From its humble beginnings as a "failed" drum machine to its status as a cultural icon, the 808 has truly earned its place in music history.
---
_The TR-808 may have been discontinued in 1983, but its beat goes on, inspiring new generations of musicians and producers to create the music of tomorrow._

View File

@@ -0,0 +1,130 @@
---
title: 'Theme Guide'
pubDate: '2025-07-10'
---
Chiri is a minimal blog theme built with [Astro](https://astro.build), offering customization options while preserving its clean aesthetic.
---
## Basic Commands
- `pnpm new <title>` - Create a new post (use `_title` for drafts)
- `pnpm update-theme` - Update the theme to the latest version
## Main Files & Directories
- `src/content/about/about.md` - Edit the about section of the index page. Leave it empty if you don't want any content.
- `src/content/posts/` - All blog posts are stored here
- `src/config.ts` - Configure main site info and settings
```ts
// Site Info
site: {
// Site domain
website: 'https://astro-chiri.netlify.app/',
// Site title
title: 'CHIRI',
// Author name
author: '3ASH',
// Site description
description: 'Minimal blog built by Astro',
// Default language
language: 'en-US'
},
```
```ts
// General Settings
general: {
// Content area width
contentWidth: '35rem',
// Use centered layout (false for left-aligned)
centeredLayout: true,
// Show theme toggle button (uses system theme by default)
themeToggle: false,
// Show dotted divider in post list
postListDottedDivider: false,
// Show footer
footer: true,
// Enable fade animations
fadeAnimation: true
},
```
```ts
// Date Settings
date: {
// Date format: YYYY-MM-DD, MM-DD-YYYY, DD-MM-YYYY, MONTH DAY YYYY, DAY MONTH YYYY
dateFormat: 'YYYY-MM-DD',
// Date separator: . - / (except for MONTH DAY YYYY and DAY MONTH YYYY)
dateSeparator: '.',
// Date position in post list (true for right, false for left)
dateOnRight: true
},
```
```ts
// Post Settings
post: {
// Show reading time in posts
readingTime: false,
// Show the table of contents (when there is enough page width)
toc: true,
// Enable image viewer
imageViewer: true,
// Enable copy button in code blocks
copyCode: true
}
```
## Post Frontmatter
Only `title` and `pubDate` are required fields
```ts
---
title: 'Post Title'
pubDate: '2025-07-10'
---
```
## Syntax Highlighting
You can configure the theme via `shikiConfig` in `astro.config.ts`.
More details: [Syntax Highlighting | Astro Docs](https://docs.astro.build/en/guides/syntax-highlighting/)
```ts
import { defineConfig } from 'astro/config'
export default defineConfig({
markdown: {
shikiConfig: {
light: 'github-light',
dark: 'github-dark',
wrap: false
}
}
})
```
---
## Preview of Some Features
![Theme Toggle](./_assets/theme-toggle.png)
![Dotted Divider](./_assets/dotted-divider.png)
![Date on Left Side](./_assets/date-on-left.png)
![Table of Contents](./_assets/toc.png)
![Reading Time](./_assets/reading-time.png)
![Copy Code](./_assets/copy-code.png)

View File

@@ -0,0 +1,31 @@
---
title: 'Using MDX'
pubDate: '2025-05-12'
---
import Callout from '@/components/examples/Callout.astro'
import CounterButton from '@/components/examples/CounterButton.astro'
import Tag from '@/components/examples/Tag.astro'
MDX combines Markdown with embedded JavaScript and JSX, making it easy to build interactive content. Below are some examples.
---
## Callout
<Callout />
## Button
<CounterButton />
## Tag
<Tag />
---
## Ref
- [MDX Syntax Documentation](https://mdxjs.com/docs/what-is-mdx)
- [Astro Usage Documentation](https://docs.astro.build/en/guides/markdown-content/#markdown-and-mdx-pages)

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

@@ -0,0 +1,12 @@
/// <reference types="astro/client" />
/// <reference types="astro/content" />
declare module 'astro:content' {
interface Render {
'.md': Promise<{
Content: import('astro').MarkdownInstance<Record<string, unknown>>['Content']
headings: import('astro').MarkdownHeading[]
remarkPluginFrontmatter: Record<string, unknown>
}>
}
}

View File

@@ -0,0 +1,61 @@
---
import { themeConfig } from '@/config'
import { ClientRouter } from 'astro:transitions'
import ThemeManager from '@/components/ui/ThemeManager.astro'
import FaviconThemeSwitcher from '@/components/ui/FaviconThemeSwitcher.astro'
import TransitionWrapper from '@/components/layout/TransitionWrapper.astro'
import type { LayoutProps } from '@/types'
type Props = LayoutProps
const { type = 'page' } = Astro.props
const language = themeConfig.site.language || 'en-US'
const contentWidth = themeConfig.general.contentWidth
const fadeAnimation = themeConfig.general.fadeAnimation
const widthValue = Math.min(parseFloat(contentWidth), 50)
const shouldUseCustomWidth = widthValue > 25
const finalWidth = shouldUseCustomWidth ? `${widthValue}rem` : '25rem'
---
<html lang={language}>
<head>
{fadeAnimation && <ClientRouter />}
<slot name="head" />
</head>
<body
data-centered={themeConfig.general.centeredLayout}
style={`
max-width: ${finalWidth};
${shouldUseCustomWidth ? `--content-width: ${widthValue}rem;` : ''}
`}
>
<ThemeManager />
<FaviconThemeSwitcher />
{
fadeAnimation ? (
<TransitionWrapper type={type} class="layout-wrapper">
<slot />
</TransitionWrapper>
) : (
<div class="layout-wrapper">
<slot />
</div>
)
}
</body>
</html>
<style is:global>
.layout-wrapper {
display: flex;
flex-direction: column;
min-height: calc(100vh - 7.5rem);
}
@media (max-width: 768px) {
.layout-wrapper {
min-height: calc(100vh - 5.5rem);
}
}
</style>

View File

@@ -0,0 +1,43 @@
---
import '@/styles/global.css'
import BaseHead from '@/components/layout/BaseHead.astro'
import Header from '@/components/layout/Header.astro'
import Footer from '@/components/layout/Footer.astro'
import BaseLayout from '@/layouts/BaseLayout.astro'
import GradientMask from '@/components/ui/GradientMask.astro'
import { themeConfig } from '@/config'
const { title, description } = Astro.props
---
<BaseLayout title={title} description={description} type="page">
<BaseHead title={title} description={description} slot="head" />
<div class="page-content">
<GradientMask />
<div>
<Header />
</div>
<main>
<slot />
</main>
{
themeConfig.general.footer && (
<div>
<Footer />
</div>
)
}
</div>
</BaseLayout>
<style is:global>
.page-content {
flex: 1;
display: flex;
flex-direction: column;
}
.page-content main {
flex: 1;
}
</style>

View File

@@ -0,0 +1,82 @@
---
import '@/styles/global.css'
import type { PostLayoutProps } from '@/types'
import FormattedDate from '@/components/widgets/FormattedDate.astro'
import FootnoteScroll from '@/components/widgets/FootnoteScroll.astro'
import BaseHead from '@/components/layout/BaseHead.astro'
import Footer from '@/components/layout/Footer.astro'
import BackButton from '@/components/ui/BackButton.astro'
import TableOfContents from '@/components/ui/TableOfContents.astro'
import GradientMask from '@/components/ui/GradientMask.astro'
import ImageViewer from '@/components/ui/ImageViewer.astro'
import GitHubCard from '@/components/ui/GitHubCard.astro'
import LinkCard from '@/components/ui/LinkCard.astro'
import ImageOptimizer from '@/components/ui/ImageOptimizer.astro'
import XPOST from '@/components/ui/XPOST.astro'
import CopyCode from '@/components/ui/CopyCode.astro'
import BaseLayout from '@/layouts/BaseLayout.astro'
import { themeConfig } from '@/config'
const { title, pubDate, readingTime, toc } = Astro.props as PostLayoutProps
const postSlug = Astro.url.pathname.split('/').filter(Boolean).pop() || ''
const ogImage = `/open-graph/${postSlug}.png`
---
<BaseLayout
title={`${title} · ${themeConfig.site.title}`}
description={themeConfig.site.description}
type="post"
>
<BaseHead
title={`${title} · ${themeConfig.site.title}`}
description={themeConfig.site.description}
ogImage={ogImage}
slot="head"
/>
<div class="post-container">
<main>
<div class="prose">
<GradientMask />
<BackButton />
{themeConfig.post.toc && <TableOfContents toc={toc} />}
<div class="title">
<h1>{title}</h1>
<div class="date">
<FormattedDate date={pubDate} context="post" />
{
themeConfig.post.readingTime && readingTime && (
<span class="reading-time">
<span class="separator">·</span>
{readingTime.text}
</span>
)
}
</div>
</div>
<slot />
</div>
</main>
<FootnoteScroll />
<CopyCode />
<GitHubCard />
<LinkCard />
<XPOST />
<ImageOptimizer />
{themeConfig.post.imageViewer && <ImageViewer />}
{themeConfig.general.footer && <Footer />}
</div>
</BaseLayout>
<style>
.post-container {
display: flex;
flex-direction: column;
flex: 1;
}
.post-container main {
flex: 1;
}
</style>

13
src/pages/404.astro Normal file
View File

@@ -0,0 +1,13 @@
---
import { themeConfig } from '@/config'
import IndexLayout from '@/layouts/IndexLayout.astro'
---
<IndexLayout title={`404 - ${themeConfig.site.title}`} description="Not Found">
<style>
.error-container {
color: var(--text-secondary);
}
</style>
<p class="error-container">Page Not Found...</p>
</IndexLayout>

26
src/pages/[...slug].astro Normal file
View File

@@ -0,0 +1,26 @@
---
import { type CollectionEntry, getCollection } from 'astro:content'
import PostLayout from '@/layouts/PostLayout.astro'
import { render } from 'astro:content'
export async function getStaticPaths() {
const posts = await getCollection('posts')
return posts
.filter((post) => !post.id.startsWith('_'))
.map((post) => ({
params: { slug: post.id },
props: post
}))
}
type Props = CollectionEntry<'posts'>
const post = Astro.props
const { Content, remarkPluginFrontmatter } = await render(post)
const readingTime = remarkPluginFrontmatter.readingTime
const toc = remarkPluginFrontmatter.toc || []
---
<PostLayout {...post.data} readingTime={readingTime} toc={toc}>
<Content />
</PostLayout>

33
src/pages/api/proxy.ts Normal file
View File

@@ -0,0 +1,33 @@
export const prerender = false
import type { APIContext } from 'astro'
export async function GET(context: APIContext) {
const host = context.request.headers.get('host') || 'localhost:4321'
const url = new URL(context.request.url, `http://${host}`)
const target = url.searchParams.get('url')
if (!target) {
return new Response('Missing url param', { status: 400 })
}
try {
const res = await fetch(target, {
headers: {
'User-Agent': 'Mozilla/5.0'
}
})
const contentType = res.headers.get('content-type') || 'text/html'
const data = await res.text()
return new Response(data, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*'
}
})
} catch {
return new Response('Proxy error', { status: 500 })
}
}

6
src/pages/atom.xml.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { APIContext } from 'astro'
import { generateAtom } from '@/utils/feed'
export async function GET(context: APIContext) {
return generateAtom(context)
}

16
src/pages/index.astro Normal file
View File

@@ -0,0 +1,16 @@
---
import IndexLayout from '@/layouts/IndexLayout.astro'
import About from '@/components/widgets/About.astro'
import PostList from '@/components/widgets/PostList.astro'
import { themeConfig } from '@/config'
import { getSortedFilteredPosts } from '@/utils/draft'
const posts = await getSortedFilteredPosts()
---
<IndexLayout title={themeConfig.site.title} description={themeConfig.site.description}>
<About />
<main>
<PostList posts={posts} />
</main>
</IndexLayout>

View File

@@ -0,0 +1,49 @@
import { getCollection } from 'astro:content'
import { OGImageRoute } from 'astro-og-canvas'
import { themeConfig } from '../../config'
const collectionEntries = await getCollection('posts')
// Map the array of content collection entries to create an object.
// Converts [{ id: 'post.md', data: { title: 'Example', pubDate: Date } }]
// to { 'post.md': { title: 'Example', pubDate: Date } }
const pages = Object.fromEntries(
collectionEntries.map(({ id, data }) => [id.replace(/\.(md|mdx)$/, ''), data])
)
export const { getStaticPaths, GET } = OGImageRoute({
param: 'route',
pages,
getImageOptions: (_path, page) => ({
title: page.title,
description: themeConfig.site.title,
logo: {
path: 'public/og/og-logo.png',
size: [80, 80]
},
bgGradient: [[255, 255, 255]],
bgImage: {
path: 'public/og/og-bg.png',
fit: 'fill'
},
padding: 64,
font: {
title: {
color: [28, 28, 28],
size: 68,
weight: 'SemiBold',
families: ['Inter']
},
description: {
color: [180, 180, 180],
size: 40,
weight: 'Medium',
families: ['Inter']
}
},
fonts: [
'https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-600-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-500-normal.ttf'
]
})
})

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

@@ -0,0 +1,6 @@
import type { APIContext } from 'astro'
import { generateRSS } from '@/utils/feed'
export async function GET(context: APIContext) {
return generateRSS(context)
}

View File

@@ -0,0 +1,35 @@
import { visit } from 'unist-util-visit'
/**
* Rehype plugin to cleanup and extract raw figure elements from paragraph nodes
*/
export default function rehypeCleanup() {
return (tree) => {
visit(tree, 'element', (node, index, parent) => {
if (node.tagName !== 'p') {
return
}
if (!node.children?.length) {
return
}
if (!parent) {
return
}
const rawFigureNodes = []
for (const child of node.children) {
if (child.type === 'raw' && child.value && child.value.trim().startsWith('<figure')) {
rawFigureNodes.push(child)
} else if (child.type !== 'text' || child.value.trim() !== '') {
return
}
}
if (rawFigureNodes.length > 0) {
parent.children.splice(index, 1, ...rawFigureNodes)
return index
}
})
}
}

View File

@@ -0,0 +1,38 @@
import { visit } from 'unist-util-visit'
/**
* Rehype plugin that adds copy button to code blocks for easy code copying functionality
*/
export default function rehypeCopyCode() {
return (tree) => {
visit(tree, 'element', (node) => {
if (node.tagName === 'pre') {
if (!node.children || node.children.length === 0) {
return
}
const codeElement = node.children.find((child) => child.tagName === 'code')
if (!codeElement) {
return
}
node.properties = node.properties || {}
node.properties.className = node.properties.className || []
node.properties.className.push('copy-code-block')
const copyButton = {
type: 'element',
tagName: 'button',
properties: {
className: ['copy-button'],
type: 'button',
'aria-label': 'Copy code'
},
children: []
}
node.children.unshift(copyButton)
}
})
}
}

View File

@@ -0,0 +1,92 @@
import { visit } from 'unist-util-visit'
import { themeConfig } from '../config.ts'
/**
* Rehype plugin that processes images in markdown content:
* - Wraps images with alt text in figure/figcaption elements
* - Adds data-preview attribute for image viewer functionality
* - Adds lazy loading for better performance
* - Handles multiple images in a single paragraph
*/
export default function rehypeImageProcessor() {
return (tree) => {
visit(tree, 'element', (node, index, parent) => {
if (node.tagName !== 'p') {
return
}
if (!parent || typeof index !== 'number') {
return
}
const imgNodes = []
let hasNonImageContent = false
for (const child of node.children) {
if (child.type === 'element' && child.tagName === 'img') {
imgNodes.push(child)
} else if (child.type !== 'text' || child.value.trim() !== '') {
hasNonImageContent = true
}
}
if (hasNonImageContent || imgNodes.length === 0) {
return
}
const newNodes = []
for (const imgNode of imgNodes) {
const alt = imgNode.properties?.alt?.trim()
// Enhanced image properties with performance optimizations
imgNode.properties = {
...imgNode.properties,
'data-preview': themeConfig.post.imageViewer ? 'true' : 'false',
// Add lazy loading for better performance
loading: 'lazy',
// Add decoding hint for better performance
decoding: 'async',
// Add fetchpriority for critical images (first image gets high priority)
fetchpriority: newNodes.length === 0 ? 'high' : 'auto',
class: [...(imgNode.properties.class || []), 'img-placeholder']
}
if (!alt || alt.includes('_')) {
newNodes.push(imgNode)
continue
}
const figure = {
type: 'element',
tagName: 'figure',
properties: {
className: ['image-caption-wrapper']
},
children: [
imgNode,
{
type: 'element',
tagName: 'figcaption',
properties: {
className: ['img-caption']
},
children: [
{
type: 'text',
value: alt
}
]
}
]
}
newNodes.push(figure)
}
if (newNodes.length > 0) {
parent.children.splice(index, 1, ...newNodes)
return index + newNodes.length - 1
}
})
}
}

View File

@@ -0,0 +1,214 @@
import { visit } from 'unist-util-visit'
/**
* A remark plugin that converts custom directives to embedded media HTML elements
* Supports: link cards, Spotify, YouTube, Bilibili, X posts, and GitHub repository cards
*/
const embedHandlers = {
// Link Card
link: (node) => {
const url = node.attributes?.url
if (!url) {
return false
}
// Create the LinkCard HTML structure - all metadata will be fetched by JavaScript
return `
<div class="link-card-wrapper">
<a href="${url}" class="link-card" target="_blank" rel="noopener noreferrer" data-url="${url}">
<div class="link-card-content">
<p class="link-card-title" style="display: none;"></p>
<p class="link-card-description" style="display: none;"></p>
<div class="link-card-url">Loading...</div>
</div>
<div class="link-card-image-outer">
<div class="link-card-image" style="display: none;">
<img src="" alt="" loading="lazy" />
</div>
</div>
</a>
</div>
`
},
// Spotify
spotify: (node) => {
const url = node.attributes?.url ?? ''
if (!url) {
return false
}
if (!/^https:\/\/open\.spotify\.com\//.test(url)) {
return false
}
let embedUrl = url.replace('open.spotify.com/', 'open.spotify.com/embed/')
if (!embedUrl.includes('utm_source=')) {
embedUrl += (embedUrl.includes('?') ? '&' : '?') + 'utm_source=generator'
}
let height = '152'
if (
url.includes('/album/') ||
url.includes('/playlist/') ||
url.includes('/artist/') ||
url.includes('/show/')
) {
height = '352'
}
return `
<figure>
<iframe
style="border-radius:12px"
src="${embedUrl}"
width="100%"
height="${height}"
frameBorder="0"
allowfullscreen=""
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
></iframe>
</figure>
`
},
// Youtube
youtube: (node) => {
let videoId = node.attributes?.id ?? ''
const url = node.attributes?.url ?? ''
if (!videoId && url) {
const match = url.match(/(?:v=|\/embed\/|youtu\.be\/)([\w-]{11})/)
if (match) videoId = match[1]
}
if (!videoId) {
return false
}
return `
<figure>
<iframe
src="https://www.youtube.com/embed/${videoId}"
title="YouTube video player"
loading="lazy"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</figure>
`
},
// Bilibili
bilibili: (node) => {
let bvid = node.attributes?.id ?? ''
const url = node.attributes?.url ?? ''
if (!bvid && url) {
const match = url.match(/\/BV([\w]+)/)
if (match) bvid = 'BV' + match[1]
}
if (!bvid) {
return false
}
return `
<figure>
<iframe
src="//player.bilibili.com/player.html?isOutside=true&bvid=${bvid}&p=1&autoplay=0&muted=0"
title="Bilibili video player"
loading="lazy"
scrolling="no"
border="0"
frameborder="no"
framespacing="0"
allowfullscreen="true"
></iframe>
</figure>
`
},
// X Post Card
x: (node) => {
const xUrl = node.attributes?.url ?? ''
if (!xUrl) {
return false
}
const twitterUrl = xUrl.replace(/(\w+:\/\/)?x\.com\//g, '$1twitter.com/')
const uniqueId = `x-card-${Math.random().toString(36).slice(2, 11)}`
return `
<figure class="x-card">
<blockquote class="twitter-tweet" data-dnt="true" id="${uniqueId}">
<a href="${twitterUrl}"></a>
</blockquote>
</figure>
`
},
// Github Repository Card
github: (node) => {
const repo = node.attributes?.repo ?? ''
if (!repo) {
console.warn(`Missing GitHub repository`)
return false
}
const [owner, name] = repo.split('/')
if (!owner || !name) {
console.warn(`Invalid GitHub repository format: "${repo}"`)
return false
}
return `
<a href="https://github.com/${repo}" class="gc-container" target="_blank" rel="noopener noreferrer" data-repo="${repo}">
<div class="gc-title-bar">
<div class="gc-owner-avatar" style="background-size: cover; background-position: center;" aria-hidden="true"></div>
<span class="gc-repo-title">
<span>${owner}<span class="gc-slash" aria-hidden="true">/</span><strong>${name}</strong></span>
</span>
<svg class="gc-github-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 1C5.9225 1 1 5.9225 1 12C1 16.8675 4.14875 20.9787 8.52125 22.4362C9.07125 22.5325 9.2775 22.2025 9.2775 21.9137C9.2775 21.6525 9.26375 20.7862 9.26375 19.865C6.5 20.3737 5.785 19.1912 5.565 18.5725C5.44125 18.2562 4.905 17.28 4.4375 17.0187C4.0525 16.8125 3.5025 16.3037 4.42375 16.29C5.29 16.2762 5.90875 17.0875 6.115 17.4175C7.105 19.0812 8.68625 18.6137 9.31875 18.325C9.415 17.61 9.70375 17.1287 10.02 16.8537C7.5725 16.5787 5.015 15.63 5.015 11.4225C5.015 10.2262 5.44125 9.23625 6.1425 8.46625C6.0325 8.19125 5.6475 7.06375 6.2525 5.55125C6.2525 5.55125 7.17375 5.2625 9.2775 6.67875C10.1575 6.43125 11.0925 6.3075 12.0275 6.3075C12.9625 6.3075 13.8975 6.43125 14.7775 6.67875C16.8813 5.24875 17.8025 5.55125 17.8025 5.55125C18.4075 7.06375 18.0225 8.19125 17.9125 8.46625C18.6138 9.23625 19.04 10.2125 19.04 11.4225C19.04 15.6437 16.4688 16.5787 14.0213 16.8537C14.42 17.1975 14.7638 17.8575 14.7638 18.8887C14.7638 20.36 14.75 21.5425 14.75 21.9137C14.75 22.2025 14.9563 22.5462 15.5063 22.4362C19.8513 20.9787 23 16.8537 23 12C23 5.9225 18.0775 1 12 1Z"></path>
</svg>
</div>
<p class="gc-repo-description">Loading...</p>
<div class="gc-info-bar">
<svg class="gc-info-icon" height="16" width="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z"></path>
</svg>
<span class="gc-stars-count" aria-label="Stars count">--</span>
<svg class="gc-info-icon" height="16" width="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z"></path>
</svg>
<span class="gc-forks-count" aria-label="Forks count">--</span>
<svg class="gc-info-icon" height="16" width="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z"></path>
</svg>
<span class="gc-license-info" aria-label="License">--</span>
</div>
</a>
`
}
}
export default function remarkEmbeddedMedia() {
return (tree) => {
visit(tree, ['leafDirective', 'containerDirective', 'textDirective'], (node) => {
const handler = embedHandlers[node.name]
if (!handler) {
return
}
const htmlContent = handler(node)
if (!htmlContent) {
return
}
node.type = 'html'
node.value = htmlContent
delete node.name
delete node.attributes
delete node.children
})
}
}

View File

@@ -0,0 +1,21 @@
import { toString } from 'mdast-util-to-string'
import getReadingTime from 'reading-time'
/**
* Remark plugin to calculate and add reading time information to markdown frontmatter
*/
export default function remarkReadingTime() {
return function (tree, file) {
const textOnPage = toString(tree)
const readingTime = getReadingTime(textOnPage)
const minutes = Math.max(1, Math.round(readingTime.minutes))
file.data.astro.frontmatter.minutesRead = `${minutes}min`
file.data.astro.frontmatter.readingTime = {
text: `${minutes}min`,
minutes: minutes,
time: readingTime.time,
words: readingTime.words
}
}
}

View File

@@ -0,0 +1,89 @@
import { visit } from 'unist-util-visit'
export default function remarkTOC() {
return function (tree, file) {
const headings = []
let headingIndex = 0
const usedSlugs = new Set()
// Extract headings from AST
visit(tree, 'heading', (node) => {
const level = node.depth
// Only process h1, h2, h3
if (level > 3) return
// Skip the first h1
if (level === 1 && headingIndex === 0) {
headingIndex++
return
}
const text = extractTextContent(node)
if (!text) return
// Generate unique slug from text
const slug = generateUniqueSlug(text, usedSlugs)
const id = slug
if (!node.data) node.data = {}
if (!node.data.hProperties) node.data.hProperties = {}
node.data.hProperties.id = id
headings.push({
level,
text,
id,
index: headingIndex
})
headingIndex++
})
// Store TOC data in file.data.astro.frontmatter
if (!file.data.astro) file.data.astro = {}
if (!file.data.astro.frontmatter) file.data.astro.frontmatter = {}
file.data.astro.frontmatter.toc = headings
}
}
function extractTextContent(node) {
let text = ''
visit(node, 'text', (textNode) => {
text += textNode.value
})
return text.trim()
}
// Generate a slug from text
function generateSlug(text) {
return (
text
.toLowerCase()
// Keep Chinese characters, English letters, numbers, spaces and hyphens
.replace(/[^\u4e00-\u9fa5a-z0-9\s-]/g, '')
// Replace spaces with hyphens
.replace(/\s+/g, '-')
// Replace multiple hyphens with single hyphen
.replace(/-+/g, '-')
// Remove leading and trailing hyphens
.replace(/^-|-$/g, '')
)
}
// Generate a unique slug from text
function generateUniqueSlug(text, usedSlugs) {
let slug = generateSlug(text)
let counter = 1
let uniqueSlug = slug
while (usedSlugs.has(uniqueSlug)) {
uniqueSlug = `${slug}-${counter}`
counter++
}
usedSlugs.add(uniqueSlug)
return uniqueSlug
}

21
src/styles/fonts.css Normal file
View File

@@ -0,0 +1,21 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/Inter.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Besley';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/Besley-Italic.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

212
src/styles/global.css Normal file
View File

@@ -0,0 +1,212 @@
@import './fonts.css';
@import './post.css';
:root {
/* Min Content Width */
--content-width: 25rem;
/* Typography */
--sans:
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell,
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--serif: Besley, Baskerville, Georgia, Cambria, 'Times New Roman', Times, serif;
--mono: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--font-size-s: 0.8125rem;
--font-size-m: 0.9375rem;
--font-size-l: 1.0625rem;
--font-weight-light: 350;
--font-weight-regular: 400;
--font-weight-bold: 500;
--spacing-s: -0.08em;
--spacing-m: -0.02em;
/* Light Mode Colors (Default) */
--bg: #ffffff;
--text-primary: rgba(0, 0, 0, 0.85);
--text-secondary: rgba(0, 0, 0, 0.4);
--text-tertiary: rgba(0, 0, 0, 0.24);
--border: rgba(0, 0, 0, 0.1);
--selection: rgba(0, 0, 0, 0.08);
--code-bg: rgba(0, 0, 0, 0.04);
--mark: #f3ffc4;
/* Syntax Theme */
--astro-code-foreground: rgba(0, 0, 0, 0.85);
--astro-code-background: rgba(0, 0, 0, 0.03);
--astro-code-token-constant: rgba(0, 0, 0, 0.85);
--astro-code-token-string: rgba(0, 0, 0, 0.85);
--astro-code-token-comment: rgba(0, 0, 0, 0.35);
--astro-code-token-keyword: rgba(0, 0, 0, 0.55);
--astro-code-token-parameter: rgba(0, 0, 0, 0.85);
--astro-code-token-function: rgba(0, 0, 0, 0.85);
--astro-code-token-string-expression: rgba(0, 0, 0, 0.55);
--astro-code-token-punctuation: rgba(0, 0, 0, 0.55);
--astro-code-token-link: rgba(0, 0, 0, 0.55);
}
/* Light Mode (Explicit) */
html.light {
--bg: #ffffff;
--text-primary: rgba(0, 0, 0, 0.85);
--text-secondary: rgba(0, 0, 0, 0.4);
--text-tertiary: rgba(0, 0, 0, 0.24);
--border: rgba(0, 0, 0, 0.1);
--selection: rgba(0, 0, 0, 0.08);
--code-bg: rgba(0, 0, 0, 0.04);
--mark: #f3ffc4;
/* Syntax Theme */
--astro-code-foreground: rgba(0, 0, 0, 0.85);
--astro-code-background: rgba(0, 0, 0, 0.03);
--astro-code-token-constant: rgba(0, 0, 0, 0.85);
--astro-code-token-string: rgba(0, 0, 0, 0.85);
--astro-code-token-comment: rgba(0, 0, 0, 0.35);
--astro-code-token-keyword: rgba(0, 0, 0, 0.5);
--astro-code-token-parameter: rgba(0, 0, 0, 0.85);
--astro-code-token-function: rgba(0, 0, 0, 0.85);
--astro-code-token-string-expression: rgba(0, 0, 0, 0.55);
--astro-code-token-punctuation: rgba(0, 0, 0, 0.55);
--astro-code-token-link: rgba(0, 0, 0, 0.55);
}
/* Dark Mode (Explicit) */
html.dark {
--bg: #1c1c1c;
--text-primary: rgba(255, 255, 255, 0.9);
--text-secondary: rgba(255, 255, 255, 0.4);
--text-tertiary: rgba(255, 255, 255, 0.24);
--border: rgba(255, 255, 255, 0.1);
--selection: rgba(255, 255, 255, 0.08);
--code-bg: rgba(255, 255, 255, 0.04);
--mark: #545b37;
/* Syntax Theme */
--astro-code-foreground: rgba(255, 255, 255, 0.9);
--astro-code-background: rgba(255, 255, 255, 0.03);
--astro-code-token-constant: rgba(255, 255, 255, 0.9);
--astro-code-token-string: rgba(255, 255, 255, 0.9);
--astro-code-token-comment: rgba(255, 255, 255, 0.35);
--astro-code-token-keyword: rgba(255, 255, 255, 0.55);
--astro-code-token-parameter: rgba(255, 255, 255, 0.9);
--astro-code-token-function: rgba(255, 255, 255, 0.9);
--astro-code-token-string-expression: rgba(255, 255, 255, 0.55);
--astro-code-token-punctuation: rgba(255, 255, 255, 0.55);
--astro-code-token-link: rgba(255, 255, 255, 0.55);
}
html {
background-color: var(--bg);
scroll-behavior: smooth;
scrollbar-gutter: stable;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
}
body {
font-family: var(--sans);
font-feature-settings: 'ss03' 1;
font-size: var(--font-size-m);
text-autospace: normal;
text-rendering: optimizeLegibility;
line-height: 1.75;
color: var(--text-primary);
font-display: swap;
word-wrap: break-word;
overflow-wrap: break-word;
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
box-sizing: border-box;
letter-spacing: var(--spacing-m);
padding: 6rem 1.5rem 1.5rem 1.5rem;
overscroll-behavior-y: contain;
transition: background-color 0.2s ease-out;
}
@media (max-width: 768px) {
body {
padding: 4rem 1.35rem 1.35rem 1.35rem;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
::selection {
background-color: var(--selection);
}
:focus {
outline: 2px solid var(--text-tertiary);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid var(--text-tertiary);
outline-offset: 2px;
}
body[data-width] {
max-width: var(--content-width);
}
body[data-centered='true'] {
margin: 0 auto;
}
main {
flex: 1;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.date {
margin: 0;
font-weight: var(--font-weight-light);
color: var(--text-secondary);
opacity: 0.75;
flex-shrink: 0;
letter-spacing: var(--spacing-s);
font-variant-numeric: tabular-nums;
font-feature-settings:
'tnum' 1,
'zero' 0,
'cv01' 1,
'cv02' 1,
'calt' 1,
'ss03' 1,
'ordn' 1;
}
.date .month {
letter-spacing: var(--spacing-m);
}
.sr-only {
border: 0;
padding: 0;
margin: 0;
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px);
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
white-space: nowrap;
}

557
src/styles/post.css Normal file
View File

@@ -0,0 +1,557 @@
/* Base styles for all elements */
.prose * {
margin: 0;
padding: 0;
font-size: var(--font-size-m);
}
/* Main content container */
.prose {
margin-bottom: 8rem;
}
/* Post title section */
.prose .title {
margin-bottom: 2.5em;
}
.prose .title h1 {
margin: 0 0 0.375rem 0;
}
/* Headings (h1-h5)
Adjust size or spacing below if needed. */
.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5 {
font-size: var(--font-size-m);
font-weight: var(--font-weight-bold);
line-height: 1.75;
margin: 3.75em 0 1.75em 0;
}
/* Bold text */
.prose strong,
.prose b {
font-weight: var(--font-weight-bold);
}
/* Italic text */
.prose em {
font-family: var(--serif);
font-style: italic;
letter-spacing: 0;
}
/* Links */
.prose a {
color: var(--primary);
text-decoration: underline;
text-decoration-color: var(--text-tertiary);
transition: text-decoration-color 0.2s ease-out;
}
.prose a:hover {
color: var(--primary);
text-decoration-color: var(--text-primary);
}
/* Paragraphs */
.prose p {
line-height: 1.75;
margin: 1.75em 0;
}
/* Tables */
.prose table {
table-layout: fixed;
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 1.75em 0;
font-size: var(--font-size-m);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
/* Table cells */
.prose th,
.prose td {
border: none;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 0.5em 1em;
text-align: left;
}
.prose th:last-child,
.prose td:last-child {
border-right: none;
}
.prose tr:last-child td {
border-bottom: none;
}
.prose th {
background: var(--astro-code-background);
font-weight: var(--font-weight-bold);
}
/* Images */
.prose img {
max-width: 100%;
height: auto;
display: block;
margin: 2em 0;
}
.img-placeholder {
background: var(--code-bg);
display: block;
}
/* Loading state for images */
.prose img[loading='lazy'] {
opacity: 0;
animation: fadeIn 0.3s ease-out forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.prose figure {
margin-bottom: 2em;
text-align: center;
}
.prose figure img {
margin-bottom: 1em;
}
.prose figure figcaption {
color: var(--text-secondary);
font-size: var(--font-size-s);
text-align: center;
}
.prose p > img {
position: relative;
margin-bottom: 2em;
}
.prose p > img::after {
content: attr(alt);
display: block;
position: absolute;
left: 0;
width: 100%;
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-s);
margin-top: 0.75em;
}
.prose .img-caption {
display: block;
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-s);
margin-bottom: 2em;
}
/* Inline code */
.prose code {
padding: 2.5px 3.5px;
border-radius: 5px;
background-color: var(--code-bg);
border: 0.5px solid var(--border);
font-family: var(--mono);
font-size: 0.9em;
font-feature-settings:
'liga' 0,
'calt' 0;
-webkit-font-feature-settings:
'liga' 0,
'calt' 0;
}
/* Blockquotes */
.prose blockquote {
border-left: 1.5px solid var(--border);
margin: 0 0 1.75em 0.125em;
padding: 0 0 0 1.5em;
text-align: left;
}
.prose blockquote p {
margin: 0;
}
.prose blockquote cite {
display: inline-block;
margin-top: 0.5em;
}
/* Unordered lists */
.prose ul {
list-style-type: none;
padding-left: 0;
margin-left: 1rem;
margin-bottom: 1.75em;
line-height: 1.75;
}
.prose ul ul {
margin-left: 0.625rem;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.prose ul li {
position: relative;
padding-left: 0.5rem;
margin-bottom: 0.5em;
}
.prose ul li:last-child {
margin-bottom: 0;
}
.prose ul li > ul {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.prose ul li::before {
content: '•';
position: absolute;
left: -1.25rem;
top: -0.05em;
width: 1.5rem;
text-align: center;
color: var(--text-tertiary);
}
/* Ordered lists */
.prose ol {
list-style-position: outside;
padding-left: 0;
margin-left: 1.25rem;
margin-bottom: 1.75em;
counter-reset: item;
}
.prose ol li {
display: block;
position: relative;
padding-left: 0.25rem;
margin-bottom: 0.5em;
}
.prose ol li:last-child {
margin-bottom: 0;
}
.prose ol li::before {
content: counter(item) '.';
counter-increment: item;
position: absolute;
left: -1.15rem;
width: 1.5rem;
text-align: left;
color: var(--text-secondary);
opacity: 0.75;
font-variant-numeric: tabular-nums;
font-feature-settings:
'tnum' 1,
'zero' 0,
'cv01' 1,
'cv02' 1,
'calt' 1,
'ss03' 1,
'liga' 1,
'ordn' 1;
}
/* Task lists */
.prose ul.contains-task-list {
list-style: none;
margin-left: 0;
white-space: nowrap;
}
.prose ul.contains-task-list li::before {
content: none;
}
.prose ul.contains-task-list li.task-list-item {
padding-left: 0.125em;
margin-bottom: 0.5em;
}
.prose ul.contains-task-list li.task-list-item:last-child {
margin-bottom: 0;
}
/* Task list checkboxes */
.prose ul.contains-task-list li.task-list-item input[type='checkbox'] {
margin-right: 0.5em;
position: relative;
top: 0.175em;
width: 1em;
height: 1em;
border: 1.35px solid var(--text-tertiary);
border-radius: 4px;
background: transparent;
appearance: none;
-webkit-appearance: none;
}
.prose ul.contains-task-list li.task-list-item input[type='checkbox']:checked {
position: relative;
background: var(--code-bg);
border: 1.35px solid var(--text-tertiary);
opacity: 0.75;
}
.prose ul.contains-task-list li.task-list-item input[type='checkbox']:checked::before {
content: '✓';
font-family: var(--sans);
color: var(--text-primary);
opacity: 0.6;
font-weight: var(--font-weight-bold);
position: absolute;
left: 0.15em;
top: 0.05em;
font-size: 0.75em;
line-height: 1;
}
.prose ul.contains-task-list li.task-list-item input[type='checkbox'] + * {
display: inline;
margin-left: 0;
line-height: 1.75;
white-space: nowrap;
}
/* .prose ul.contains-task-list li.task-list-item:has(input[type='checkbox']:checked) {
text-decoration: line-through;
} */
/* Subscript and superscript */
.prose sup,
.prose sub,
.prose sup a {
margin: 0 0.125em;
font-size: 0.875em;
line-height: 1;
}
/* Horizontal rule */
.prose hr {
margin: 3.75em 0;
height: auto;
border: none;
background: none;
text-align: center;
position: relative;
}
.prose hr::before {
content: '***';
font-family: var(--mono);
color: var(--text-tertiary);
font-size: 0.875em;
letter-spacing: 0.25em;
}
/* Keyboard input */
.prose kbd {
font-family: var(--mono);
font-size: var(--font-size-s);
border: 1px solid var(--text-tertiary);
padding: 1px 4px;
border-radius: 5px;
min-width: 1.75em;
display: inline-block;
text-align: center;
/* box-shadow: inset 0 -2.5px 0 var(--border); */
}
/* Highlighted text */
.prose mark {
background-color: var(--mark);
color: var(--text-primary);
padding: 3px 1px;
}
/* Footnotes */
.prose .footnotes {
margin-top: 4rem;
padding-top: 1.75rem;
position: relative;
}
.prose .footnotes::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4rem;
height: 1px;
background-color: var(--border);
}
.prose cite {
font-style: normal;
}
/* Footnote references */
.prose [data-footnote-backref] {
position: relative;
font-family: var(--mono);
font-size: var(--font-size-l);
top: -0.05em;
}
.prose [data-footnote-ref] {
font-size: 1em;
font-variant-numeric: tabular-nums;
font-feature-settings:
'tnum' 1,
'zero' 0,
'cv01' 1,
'cv02' 1,
'calt' 1,
'ss03' 1,
'liga' 1,
'ordn' 1;
}
.prose [data-footnote-ref],
.prose [data-footnote-backref] {
color: var(--text-secondary);
opacity: 0.875;
text-decoration: none;
transition: color 0.2s ease-out;
padding-right: 0.5em;
}
.prose [data-footnote-ref]:hover,
.prose [data-footnote-backref]:hover {
color: var(--text-primary);
}
/* Code blocks */
.prose pre {
background-color: var(--astro-code-background);
border-radius: 8px;
padding: 1.25em 1.5em;
margin: 2em 0;
overflow-x: auto;
}
@media (max-width: 768px) {
.prose pre {
padding: 1em 1.25em;
}
}
.prose pre > code {
font-family: var(--mono);
font-feature-settings:
'liga' 0,
'calt' 0;
display: block;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
padding: 0;
margin: 0;
background: none;
border: none;
line-height: 1.5;
border-radius: 0;
}
.prose pre > code * {
font-size: var(--font-size-s);
}
/* KaTeX Math Rendering */
/* Hide MathML fallback to prevent duplication */
.katex-mathml {
display: none !important;
}
/* Ensure display math is centered */
.katex-display {
text-align: center;
margin: 1.75em 0;
}
/* Reset any conflicting styles that might interfere with KaTeX */
.katex * {
box-sizing: content-box;
}
/* Ensure KaTeX elements inherit color properly */
.katex,
.katex * {
color: inherit;
}
/* Specific fixes for common CSS framework conflicts */
.katex .base,
.katex .strut,
.katex .mathit,
.katex .mathrm,
.katex .mathbf,
.katex .mathsf,
.katex .mathtt {
line-height: initial;
vertical-align: baseline;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.katex,
.katex * {
color: inherit;
}
}
/* Reading Time */
.reading-time {
color: var(--text-secondary);
letter-spacing: -0.025em;
}
.reading-time .separator {
margin: 0 0.25em;
}
/* Video */
.prose iframe {
width: 100%;
aspect-ratio: 16/9;
border: none;
border-radius: 6px;
margin: 0.25em 0 0 0;
}
/* Spotify */
.prose iframe[src*='spotify.com'] {
aspect-ratio: auto;
}

View File

@@ -0,0 +1,92 @@
import type { TOCItem, ReadingTime } from './content.types'
// TOC component props interface
export interface TOCProps {
toc?: TOCItem[]
}
// Post layout props interface (generic, not tied to specific data source)
export interface PostLayoutProps {
title: string
pubDate: Date
image?: string
readingTime?: ReadingTime
toc?: TOCItem[]
}
// Transition props interface
export interface TransitionProps {
type: 'post' | 'page'
class?: string
}
// Layout props interface
export interface LayoutProps extends TransitionProps {
title?: string
description?: string
}
// BaseHead component props interface
export interface BaseHeadProps {
title: string
description: string
ogImage?: string
}
// ImageOptimizer component props interface
export interface ImageOptimizerProps {
src: string | ImageMetadata
alt: string
width?: number
height?: number
quality?: number
format?: 'avif' | 'webp' | 'jpeg' | 'png'
loading?: 'lazy' | 'eager'
decoding?: 'async' | 'sync' | 'auto'
class?: string
caption?: string
priority?: boolean
}
// FormattedDate component props interface
export interface FormattedDateProps {
date: Date
format?: string
context?: 'list' | 'post' | 'default'
}
// GitHub repository data interface
export interface GitHubRepoData {
owner?: {
avatar_url: string
}
description?: string
stargazers_count?: number
forks_count?: number
license?: {
spdx_id: string
}
}
// Cached repository data interface
export interface CachedRepoData {
data: GitHubRepoData
timestamp: number
}
// GitHub card UI elements interface
export interface CardElements {
avatar: HTMLElement | null
desc: HTMLElement | null
stars: HTMLElement | null
forks: HTMLElement | null
license: HTMLElement | null
}
// LinkCard metadata interface (fetched from URL)
export interface LinkCardMetadata {
title: string
description: string
image: string
imageAlt: string
}

49
src/types/config.types.ts Normal file
View File

@@ -0,0 +1,49 @@
// Date format types
export type DateFormat =
| 'YYYY-MM-DD'
| 'MM-DD-YYYY'
| 'DD-MM-YYYY'
| 'MONTH DAY YYYY'
| 'DAY MONTH YYYY'
// Site info configuration type
export interface SiteInfo {
website: string
title: string
author: string
description: string
language: string
}
// General settings configuration type
export interface GeneralSettings {
contentWidth: string
centeredLayout: boolean
themeToggle: boolean
postListDottedDivider: boolean
footer: boolean
fadeAnimation: boolean
}
// Date settings configuration type
export interface DateSettings {
dateFormat: DateFormat
dateSeparator: string
dateOnRight: boolean
}
// Post settings configuration type
export interface PostSettings {
readingTime: boolean
toc: boolean
imageViewer: boolean
copyCode: boolean
}
// Theme configuration type
export interface ThemeConfig {
site: SiteInfo
general: GeneralSettings
date: DateSettings
post: PostSettings
}

View File

@@ -0,0 +1,20 @@
// Reading time interface
export interface ReadingTime {
text: string
minutes: number
time: number
words: number
}
// TOC item interface
export interface TOCItem {
level: number
text: string
id: string
index: number
}
// PostList component props interface
export interface PostListProps {
posts: CollectionEntry<'posts'>[]
}

8
src/types/index.ts Normal file
View File

@@ -0,0 +1,8 @@
// Configuration types
export * from './config.types'
// Content types
export * from './content.types'
// Component types
export * from './component.types'

66
src/utils/date.ts Normal file
View File

@@ -0,0 +1,66 @@
import { themeConfig } from '@/config'
import type { DateFormat } from '@/types'
const MONTHS_EN = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
]
const VALID_SEPARATORS = ['.', '-', '/']
/**
* @param date
* @param format
* @returns
*/
export function formatDate(date: Date, format?: string): string {
const formatStr = (format || themeConfig.date.dateFormat).trim()
const configSeparator = themeConfig.date.dateSeparator || '-'
const separator = VALID_SEPARATORS.includes(configSeparator.trim()) ? configSeparator.trim() : '.'
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const monthName = MONTHS_EN[date.getMonth()]
const pad = (num: number) => String(num).padStart(2, '0')
switch (formatStr) {
case 'YYYY-MM-DD':
return `${year}${separator}${pad(month)}${separator}${pad(day)}`
case 'MM-DD-YYYY':
return `${pad(month)}${separator}${pad(day)}${separator}${year}`
case 'DD-MM-YYYY':
return `${pad(day)}${separator}${pad(month)}${separator}${year}`
case 'MONTH DAY YYYY':
return `<span class="month">${monthName}</span> ${day} ${year}`
case 'DAY MONTH YYYY':
return `${day} <span class="month">${monthName}</span> ${year}`
default:
return `${year}${separator}${pad(month)}${separator}${pad(day)}`
}
}
export const SUPPORTED_DATE_FORMATS: readonly DateFormat[] = [
'YYYY-MM-DD',
'MM-DD-YYYY',
'DD-MM-YYYY',
'MONTH DAY YYYY',
'DAY MONTH YYYY'
] as const

20
src/utils/draft.ts Normal file
View File

@@ -0,0 +1,20 @@
import { getCollection, type CollectionEntry } from 'astro:content'
/**
* Get all posts, filtering out posts whose filenames start with _
*/
export async function getFilteredPosts() {
const posts = await getCollection('posts')
return posts.filter((post: CollectionEntry<'posts'>) => !post.id.startsWith('_'))
}
/**
* Get all posts sorted by publication date, filtering out posts whose filenames start with _
*/
export async function getSortedFilteredPosts() {
const posts = await getFilteredPosts()
return posts.sort(
(a: CollectionEntry<'posts'>, b: CollectionEntry<'posts'>) =>
b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
)
}

81
src/utils/feed.ts Normal file
View File

@@ -0,0 +1,81 @@
import { getCollection, type CollectionEntry } from 'astro:content'
import { themeConfig } from '@/config'
import type { APIContext } from 'astro'
export async function generateRSS(context: APIContext) {
const posts = await getCollection('posts')
const filteredPosts = posts.filter((post: CollectionEntry<'posts'>) => !post.id.startsWith('_'))
const sortedPosts = filteredPosts.sort(
(a: CollectionEntry<'posts'>, b: CollectionEntry<'posts'>) =>
b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
)
const rss = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${themeConfig.site.title}</title>
<link>${context.site}</link>
<description>${themeConfig.site.description}</description>
<language>zh-CN</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="${context.site}/rss.xml" rel="self" type="application/rss+xml" />
${sortedPosts
.map(
(post: CollectionEntry<'posts'>) => `
<item>
<title><![CDATA[${post.data.title}]]></title>
<link>${context.site}/${post.id}/</link>
<guid>${context.site}/${post.id}/</guid>
<pubDate>${post.data.pubDate.toUTCString()}</pubDate>
<content:encoded><![CDATA[${post.body}]]></content:encoded>
</item>
`
)
.join('')}
</channel>
</rss>`
return new Response(rss, {
headers: {
'Content-Type': 'application/xml; charset=utf-8'
}
})
}
export async function generateAtom(context: APIContext) {
const posts = await getCollection('posts')
const filteredPosts = posts.filter((post: CollectionEntry<'posts'>) => !post.id.startsWith('_'))
const sortedPosts = filteredPosts.sort(
(a: CollectionEntry<'posts'>, b: CollectionEntry<'posts'>) =>
b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
)
const atom = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${themeConfig.site.title}</title>
<subtitle>${themeConfig.site.description}</subtitle>
<link href="${context.site}/atom.xml" rel="self" type="application/atom+xml" />
<link href="${context.site}" />
<id>${context.site}</id>
<updated>${new Date().toISOString()}</updated>
${sortedPosts
.map(
(post: CollectionEntry<'posts'>) => `
<entry>
<title>${post.data.title}</title>
<link href="${context.site}/${post.id}/" />
<id>${context.site}/${post.id}/</id>
<published>${post.data.pubDate.toISOString()}</published>
<content type="html"><![CDATA[${post.body}]]></content>
</entry>
`
)
.join('')}
</feed>`
return new Response(atom, {
headers: {
'Content-Type': 'application/xml; charset=utf-8'
}
})
}

27
src/utils/image-config.ts Normal file
View File

@@ -0,0 +1,27 @@
export const imageConfig = {
// Enhanced image optimization settings
limitInputPixels: 268402689, // ~16K x 16K pixels
jpeg: {
quality: 85,
progressive: true,
optimizeScans: true,
mozjpeg: true
},
png: {
quality: 85,
progressive: true,
compressionLevel: 9,
adaptiveFiltering: true
},
webp: {
quality: 85,
lossless: false,
nearLossless: true,
smartSubsample: true
},
avif: {
quality: 85,
lossless: false,
speed: 5
}
}

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"strictNullChecks": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}