├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE.txt ├── Obsidian Template ├── Blog Post.md └── README.md ├── README-zh_CN.md ├── README.md ├── astro.config.mjs ├── commitlint.config.js ├── eslint.config.js ├── package.json ├── plugins ├── remark-modified-time.ts └── remark-reading-time.ts ├── pnpm-lock.yaml ├── public ├── avatar.png ├── favicon.svg ├── og_image.png └── pretty-feed-v3.xsl ├── scripts └── sync-latest-blog.sh ├── slate.config.ts ├── src ├── assets │ ├── images │ │ ├── desktop-outlined.svg │ │ ├── dribbble.svg │ │ ├── facebook.svg │ │ ├── figma.svg │ │ ├── github.svg │ │ ├── in.svg │ │ ├── instagram.svg │ │ ├── link.svg │ │ ├── mail.svg │ │ ├── moon-outlined.svg │ │ ├── notion.svg │ │ ├── rss.svg │ │ ├── sun-outlined.svg │ │ ├── threads.svg │ │ ├── x.svg │ │ └── youtube.svg │ └── style │ │ ├── blog.css │ │ └── common.css ├── components │ ├── affix-title │ │ └── index.tsx │ ├── button │ │ ├── index.css │ │ └── index.tsx │ ├── code-group-event │ │ └── index.tsx │ ├── json-ld │ │ ├── article.astro │ │ └── normal.astro │ ├── layouts │ │ ├── Footer.astro │ │ ├── HeadMeta.astro │ │ ├── Header.astro │ │ └── PageLayout.astro │ ├── search │ │ ├── index.css │ │ ├── index.tsx │ │ └── useLocales.ts │ ├── social-links │ │ ├── index.css │ │ └── index.tsx │ ├── theme-select │ │ └── index.tsx │ └── toc │ │ └── index.tsx ├── content │ ├── config.ts │ └── post │ │ ├── 40-questions.md │ │ ├── about-slate-blog.md │ │ ├── life-is-short.md │ │ ├── obsidian-vault-template.md │ │ ├── sonner-getting-started.md │ │ └── why-astro.md ├── env.d.ts ├── helpers │ ├── config-helper.ts │ └── utils.ts ├── i18n │ ├── index.ts │ └── lang │ │ ├── en-us.ts │ │ └── zh-cn.ts ├── pages │ ├── 404.astro │ ├── blog │ │ └── [...slug].astro │ ├── index.astro │ ├── robots.txt.ts │ └── rss.xml.js └── typings │ ├── config.ts │ └── global.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # jetbrains setting folder 24 | .idea/ 25 | 26 | # Obsidian config 27 | .obsidian/ -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --config commitlint.config.js --edit 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "proseWrap": "never", 5 | "plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"], 6 | "overrides": [ 7 | { 8 | "files": "*.astro", 9 | "options": { 10 | "parser": "astro" 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"], 3 | "unwantedRecommendations": [], 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.documentSelectors": ["**/*.astro"], 3 | "[astro]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "eslint.validate": [ 7 | "javascript", 8 | "javascriptreact", 9 | "astro", 10 | "typescript", 11 | "typescriptreact" 12 | ], 13 | "i18n-ally.localesPaths": [ 14 | "src/i18n", 15 | "src/i18n/lang" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 SlateDesign 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Obsidian Template/Blog Post.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 3 | description: 4 | tags: 5 | pubDate: 6 | draft: false 7 | --- 8 | -------------------------------------------------------------------------------- /Obsidian Template/README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Template 2 | 3 | ## En 4 | If you’re using Obsidian for note-taking, you can set this folder as your template folder under `Preferences -> Templates -> Template Folder Location`. 5 | 6 | ## Zh 7 | 如果你使用 Obsidian 作为笔记软件,将此文件夹设置为模板文件夹 `Preferences -> 模板 -> 模板文件夹位置`。 8 | 9 | -------------------------------------------------------------------------------- /README-zh_CN.md: -------------------------------------------------------------------------------- 1 | # Slate blog 2 | 3 | [English](./README.md) · 中文 4 | 5 | ## 我们为什么创作这样一个博客主题 6 | 我们热爱写作与分享,也很欣赏精致的互联网产品。正因如此,我们创作了这个简洁的博客主题,它专注于内容本身,提供流畅、纯粹的写作与阅读体验。而基于各种现代的技术栈,也让其更快速、轻便和高效。 7 | 8 | 它还能与 [Obsidian](https://obsidian.md/) 无缝结合,你可以轻松将笔记转化为精致的博客文章。 9 | 10 | ## ✨ 特性 11 | 12 | - 简洁优雅的设计 13 | - 移动端适配 14 | - 支持 `light` 和 `dark` 颜色模式 15 | - 0 基础快速配置和部署 16 | - 支持文章草稿,本地允许预览,生产构建自动过滤 17 | - 支持 RSS 订阅和 Follow 认证 18 | - 支持 Algolia 搜索 19 | - 完善的 SEO 支持 20 | 21 | ## 🪜 框架 22 | 23 | - Astro + React + Typescript 24 | - Tailwindcss + @radix-ui/colors 25 | - 支持 [Tailwind CSS v4.0](https://tailwindcss.com/blog/tailwindcss-v4) (Jan 10, 2025) 26 | - Docsearch 27 | 28 | ## 🔨 使用 29 | 30 | ```bash 31 | # 启动本地服务器 32 | npm run dev 33 | # or 34 | yarn dev 35 | # or 36 | pnpm dev 37 | 38 | # 构建 39 | npm run build 40 | # or 41 | yarn build 42 | # or 43 | pnpm build 44 | ``` 45 | 46 | > 如果你 Fork 仓库后,并将仓库设置为私有,默认会失去与上游仓库关联,可以通过运行 `pnpm sync-latest` 同步 Slate Blog 最新版本代码。 47 | 48 | ## 🗂 目录 49 | 50 | ``` 51 | - plugins/ # 自定义插件 52 | - src/ 53 | ├── assets/ # 图片文件 54 | ├── components/ # 组件 55 | ├── content/ # 内容 56 | ├── helpers/ # 业务逻辑 57 | ├── pages/ # 页面 58 | └── typings/ # 通用类型 59 | 60 | ``` 61 | 62 | ## 配置 63 | 64 | 通过根目录下的 `slate.config.ts` 进行主题配置。 65 | 66 | | 选项 | 说明 | 类型 | 默认值 | 67 | | --- | --- | --- | --- | 68 | | site | 最终部署的链接 | `string` | - | 69 | | title | 网站标题 | `string` | - | 70 | | description | 网站描述 | `string` | - | 71 | | lang | 语言 | `string` | `zh-CN` | 72 | | theme | 主题 | `{ mode: 'auto' \| 'light' \| 'dark', enableUserChange: boolean }` | `{ mode: 'auto', enableUserChange: true }` | 73 | | avatar | 头像 | `string` | - | 74 | | sitemap | 网站 sitemap 配置 | [SitemapOptions](https://docs.astro.build/zh-cn/guides/integrations-guide/sitemap/) | - | 75 | | readTime | 是否显示阅读时间 | `boolean` | `false` | 76 | | lastModified | 是否显示最后修改时间 | `boolean` | `false` | 77 | | algolia | docsearch 配置 | `{ appId: string, apiKey: string, indexName: string }` | - | 78 | | follow | follow 订阅认证配置 | `{ feedId: string, userId: string }` | - | 79 | | footer | 网站底部配置 | `{ copyright: string }` | - | 80 | | socialLinks | 社交链接配置 | `{ icon: [SocialLinkIcon](#SocialLinkIcon), link: string, ariaLabel?: string }` | - | 81 | 82 | ### SocialLinkIcon 83 | 84 | ```ts 85 | type SocialLinkIcon = 86 | | 'dribbble' 87 | | 'facebook' 88 | | 'figma' 89 | | 'github' 90 | | 'instagram' 91 | | 'link' 92 | | 'mail' 93 | | 'notion' 94 | | 'rss' 95 | | 'threads' 96 | | 'x' 97 | | 'youtube' 98 | | { svg: string } 99 | ``` 100 | 101 | ### algolia 申请 102 | 103 | 1. 部署网站 104 | 2. 在 [Algolia](https://docsearch.algolia.com/apply/) 申请应用 `apiKey` 105 | 3. 申请完成后且通过,在 `slate.config.ts` 中配置 `algolia` 106 | 4. 重新部署网站 107 | 108 | ### Follow 订阅认证 109 | 110 | 1. 注册 [Follow](https://follow.is/) 账号 111 | 2. 部署站点 112 | 3. 在 Follow 点击 `+` 号,选择 `RSS` 订阅,填入 `rss` 链接,一般为 `[site]/rss.xml`, `site` 为 `slate.config.ts` 配置文件中 `site` 的值。 113 | 4. 重新部署网站 114 | 115 | 116 | ## 文章 frontmatter 说明 117 | 118 | | 选项 | 说明 | 类型 | 是否必须 | 119 | | --- | --- | --- | --- | 120 | | title | 文章标题 | `string` | 是 | 121 | | description | 文章描述 | `string` | 否 | 122 | | tags | 文章标签 | `string[]` | 否 | 123 | | draft | 是否是草稿,当不传或者为 `false` 时,`pubDate` 必须传;草稿仅本地预览可见 | `boolean` | 否 | 124 | | pubDate | 文章发布时间 | `date` | 否,当 `draft` 为 `false` 时,必须传 | 125 | 126 | **详细可以查看 `src/content/config.ts` 文件** 127 | 128 | ### 示例 129 | 130 | ```md 131 | --- 132 | title: 40 questions 133 | description: This repo maintains revisons and translations to the list of 40 questions I ask myself each year and each decade. 134 | tags: 135 | - Life 136 | - Thinking 137 | - Writing 138 | pubDate: 2025-01-06 139 | --- 140 | ``` 141 | ## Markdown 语法支持 142 | 143 | 除了标准的 Markdown 语法外,我们还支持部分扩展语法。 144 | 145 | ### 基础语法 146 | - 标题、列表、引用、代码块等基础语法 147 | - 表格 148 | - 链接和图片 149 | - **粗体**、*斜体*和~删除线~文本 150 | 151 | ### 扩展语法 152 | #### 容器 153 | 使用 `:::` 标记 154 | ```md 155 | :::info 156 | 这是一个信息提示 157 | ::: 158 | ``` 159 | 160 | #### LaTeX 数学公式 161 | - 行内公式: $E = mc^2$ 162 | - 块级公式: $$ E = mc^2 $$ 163 | 164 | #### 支持图片说明 165 | ```md 166 | ![Image caption](image-url) 167 | ``` 168 | 169 | ## 更新日志 170 | ### 版本 1.3.0 171 | - 支持显示社交链接 172 | - 优化 RSS 生成 173 | - 添加同步最新版本脚本 174 | 175 | ### 版本 1.2.0 176 | - 支持多语言(中文和英语) 177 | - 修复已知问题 178 | 179 | ### 版本 1.1.1 180 | - 修复已知问题 181 | 182 | ### 版本 1.1.0 183 | - 升级支持 [Tailwind CSS v4.0](https://tailwindcss.com/blog/tailwindcss-v4) 184 | - 支持深色模式 185 | - 修复已知问题 186 | 187 | ## Star 188 | 189 | [![Star History Chart](https://api.star-history.com/svg?repos=SlateDesign/slate-blog&type=Date)](https://www.star-history.com/#SlateDesign/slate-blog&Date) 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slate blog 2 | 3 | English · [中文](./README-zh_CN.md) 4 | 5 | ## Why We build it? 6 | 7 | We love writing and sharing, and we appreciate well-crafted products. That’s why we created this minimalist theme, focusing on content itself, providing a smooth and pure writing and reading experience. Built on the latest framework, it’s faster, lighter, and more efficient. 8 | 9 | It also works seamlessly with [Obsidian](https://obsidian.md/), helping you turn your notes into published posts effortlessly. 10 | 11 | ## ✨ Features 12 | 13 | - Minimalist design theme 14 | - Mobile-first responsive layout 15 | - Light and dark mode support 16 | - Quick setup with zero configuration required 17 | - Draft mode with local preview and automatic production filtering 18 | - Built-in RSS feed with Follow authentication 19 | - Integrated Algolia search functionality 20 | - Comprehensive SEO optimization for better search rankings 21 | 22 | ## 🪜 Framework 23 | 24 | - Astro + React + Typescript 25 | - Tailwindcss + @radix-ui/colors 26 | - Updated to [Tailwind CSS v4.0](https://tailwindcss.com/blog/tailwindcss-v4) (Jan 10, 2025) 27 | - Docsearch 28 | 29 | ## 🔨 Usage 30 | 31 | ```bash 32 | # Start local server 33 | npm run dev 34 | # or 35 | yarn dev 36 | # or 37 | pnpm dev 38 | 39 | # Build 40 | npm run build 41 | # or 42 | yarn build 43 | # or 44 | pnpm build 45 | ``` 46 | 47 | > If you fork the repository and set it to private, you will lose the association with the upstream repository by default. You can sync the latest version of Slate Blog by running `pnpm sync-latest`. 48 | 49 | ## 🗂 Directory Structure 50 | 51 | ``` 52 | - plugins/ # Custom plugins 53 | - src/ 54 | ├── assets/ # Asset files 55 | ├── components/ # Components 56 | ├── content/ # Content collections 57 | ├── helpers/ # Business logic 58 | ├── pages/ # Pages 59 | └── typings/ # Common types 60 | ``` 61 | 62 | > Articles are stored in the `src/content/post` directory, supporting markdown and mdx formats. The filename is the path name. For example, `src/content/post/my-first-post.md` => `https://your-blog.com/blog/my-first-post`. 63 | 64 | ## Configuration 65 | 66 | Theme configuration is done through `slate.config.ts` in the root directory. 67 | 68 | | Option | Description | Type | Default | 69 | | --- | --- | --- | --- | 70 | | site | Final deployment link | `string` | - | 71 | | title | Website title | `string` | - | 72 | | description | Website description | `string` | - | 73 | | lang | Language | `string` | `zh-CN` | 74 | | theme | Theme | `{ mode: 'auto' \| 'light' \| 'dark', enableUserChange: boolean }` | `{ mode: 'auto', enableUserChange: true }` | 75 | | avatar | Avatar | `string` | - | 76 | | sitemap | Website sitemap configuration | [SitemapOptions](https://docs.astro.build/en/guides/integrations-guide/sitemap/) | - | 77 | | readTime | Show reading time | `boolean` | `false` | 78 | | lastModified | Show last modified time | `boolean` | `false` | 79 | | algolia | Docsearch configuration | `{ appId: string, apiKey: string, indexName: string }` | - | 80 | | follow | Follow subscription authentication configuration | `{ feedId: string, userId: string }` | - | 81 | | footer | Website footer configuration | `{ copyright: string }` | - | 82 | | socialLinks | Social Links Configuration | `{ icon: [SocialLinkIcon](#SocialLinkIcon), link: string, ariaLabel?: string }` | - | 83 | 84 | 85 | ### SocialLinkIcon 86 | 87 | ```ts 88 | type SocialLinkIcon = 89 | | 'dribbble' 90 | | 'facebook' 91 | | 'figma' 92 | | 'github' 93 | | 'instagram' 94 | | 'link' 95 | | 'mail' 96 | | 'notion' 97 | | 'rss' 98 | | 'threads' 99 | | 'x' 100 | | 'youtube' 101 | | { svg: string } 102 | ``` 103 | 104 | ### Algolia Application 105 | 106 | 1. Deploy your site first 107 | 2. Apply for an `apiKey` at [algolia](https://docsearch.algolia.com/apply/) 108 | 3. After successful application, configure `algolia` in `slate.config.ts` 109 | 4. Redeploy your site 110 | 111 | ### Follow Subscription Authentication 112 | 113 | 1. Register a [follow](https://follow.is/) account 114 | 2. Deploy your site 115 | 3. Click the `+` button on Follow, select `RSS` subscription, and enter the `rss` link (usually `[site]/rss.xml`, where `site` is the value of `site` in `slate.config.ts`) 116 | 4. Redeploy 117 | 118 | ## Article Frontmatter Description 119 | 120 | | Option | Description | Type | Required | 121 | | --- | --- | --- | --- | 122 | | title | Article title | `string` | Yes | 123 | | description | Article description | `string` | No | 124 | | tags | Article tags | `string[]` | No | 125 | | draft | Whether it's a draft. When not provided or `false`, `pubDate` must be provided; drafts are only visible in local preview | `boolean` | No | 126 | | pubDate | Article publication date | `date` | No, required when `draft` is `false` | 127 | 128 | **For more details, check the `src/content/config.ts` file** 129 | 130 | ### Example 131 | 132 | ```md 133 | --- 134 | title: 40 questions 135 | description: This repo maintains revisons and translations to the list of 40 questions I ask myself each year and each decade. 136 | tags: 137 | - Life 138 | - Thinking 139 | - Writing 140 | pubDate: 2025-01-06 141 | --- 142 | ``` 143 | 144 | ## Markdown Syntax Support 145 | 146 | In addition to standard Markdown syntax, the following extended syntax is supported: 147 | 148 | ### Basic Syntax 149 | - Headers, lists, blockquotes, code blocks and other basic syntax 150 | - Tables 151 | - Links and images 152 | - **Bold**, *italic*, and ~strikethrough~ text 153 | 154 | ### Extended Syntax 155 | #### Container syntax 156 | Using `:::` markers 157 | ```md 158 | :::info 159 | This is an information prompt 160 | ::: 161 | ``` 162 | 163 | #### LaTeX Mathematical Formulas 164 | - Inline formula: $E = mc^2$ 165 | - Block formula: $$ E = mc^2 $$ 166 | 167 | #### Support for image captions 168 | ```md 169 | ![Image caption](image-url) 170 | ``` 171 | 172 | ## Updates 173 | ### Version 1.3.0 174 | - Support Social Links 175 | - Optimize RSS article detail generation. 176 | - Add a script to synchronize the latest slate-blog version 177 | 178 | ### Version 1.2.0 179 | - Support i18n (English and Chinese) 180 | - Fixed known issues 181 | 182 | ### Version 1.1.1 183 | - Fixed known issues 184 | 185 | ### Version 1.1.0 186 | - Upgraded to support [Tailwind CSS v4.0](https://tailwindcss.com/blog/tailwindcss-v4) 187 | - Added dark mode support 188 | - Fixed known issues 189 | 190 | ## Star History 191 | 192 | [![Star History Chart](https://api.star-history.com/svg?repos=SlateDesign/slate-blog&type=Date)](https://www.star-history.com/#SlateDesign/slate-blog&Date) 193 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | import react from '@astrojs/react'; 3 | import svgr from 'vite-plugin-svgr'; 4 | import tailwindcss from "@tailwindcss/vite"; 5 | import mdx from '@astrojs/mdx'; 6 | import sitemap from '@astrojs/sitemap'; 7 | import remarkGemoji from 'remark-gemoji'; 8 | import remarkMath from 'remark-math'; 9 | import rehypeKatex from 'rehype-katex'; 10 | import codeImport from 'remark-code-import'; 11 | import remarkBlockContainers from 'remark-block-containers'; 12 | import astroExpressiveCode from 'astro-expressive-code'; 13 | import rehypeFigure from 'rehype-figure'; 14 | 15 | import { remarkModifiedTime } from './plugins/remark-modified-time'; 16 | import { remarkReadingTime } from './plugins/remark-reading-time'; 17 | import slateConfig from './slate.config'; 18 | 19 | function computedIntegrations() { 20 | const result = [astroExpressiveCode(), mdx(), react(), sitemap(slateConfig.sitemap)]; 21 | 22 | return result; 23 | } 24 | 25 | function generateAstroConfigure() { 26 | const astroConfig = { 27 | site: slateConfig.site, 28 | integrations: computedIntegrations(), 29 | markdown: { 30 | remarkPlugins: [ 31 | remarkGemoji, 32 | remarkMath, 33 | codeImport, 34 | // [codesandbox, { mode: 'button' }], 35 | remarkBlockContainers, 36 | ], 37 | rehypePlugins: [rehypeKatex, rehypeFigure], 38 | }, 39 | vite: { 40 | plugins: [ 41 | svgr(), 42 | tailwindcss(), 43 | ], 44 | }, 45 | }; 46 | 47 | if (slateConfig.lastModified) { 48 | astroConfig.markdown.remarkPlugins.push(remarkModifiedTime); 49 | } 50 | 51 | if (slateConfig.readTime) { 52 | astroConfig.markdown.remarkPlugins.push(remarkReadingTime); 53 | } 54 | 55 | return astroConfig; 56 | } 57 | 58 | // https://astro.build/config 59 | export default defineConfig(generateAstroConfigure()); 60 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@commitlint/config-angular'], 3 | }; 4 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import { configs as astroEslintConfigs } from 'eslint-plugin-astro'; 3 | import astroEslintParser from 'astro-eslint-parser'; 4 | import pluginJs from '@eslint/js'; 5 | import tseslint from 'typescript-eslint'; 6 | // import pluginReactConfig from 'eslint-plugin-react/configs/recommended.js'; 7 | 8 | export default [ 9 | pluginJs.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | ...astroEslintConfigs.recommended, 12 | ...astroEslintConfigs['jsx-a11y-recommended'], 13 | // pluginReactConfig, 14 | { 15 | ignores: ['node_modules', 'dist', '.astro', 'src/env.d.ts', '**/.obsidian'], 16 | }, 17 | { 18 | languageOptions: { 19 | globals: { 20 | ...globals.browser, 21 | ...globals.node, 22 | }, 23 | }, 24 | }, 25 | { 26 | files: ['**/*.astro'], 27 | processor: 'astro/client-side-ts', 28 | languageOptions: { 29 | parser: astroEslintParser, 30 | parserOptions: { 31 | parser: '@typescript-eslint/parser', 32 | extraFileExtensions: ['.astro'], 33 | }, 34 | }, 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slate-blog", 3 | "version": "1.0.0", 4 | "description": "Pure thoughts, simple stories", 5 | "type": "module", 6 | "homepage": "https://github.com/leezhian/simple-blog.git", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/leezhian/simple-blog.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/leezhian/simple-blog.git/issues" 13 | }, 14 | "sideEffects": false, 15 | "author": "kim, bluepikachu", 16 | "scripts": { 17 | "prepare": "husky", 18 | "dev": "astro dev", 19 | "start": "astro dev", 20 | "build": "npm run tsc && astro check && astro build", 21 | "preview": "astro preview", 22 | "astro": "astro", 23 | "tsc": "tsc --noEmit", 24 | "lint": "npm run tsc && npm run lint:script && astro check", 25 | "lint-fix": "npm run lint-fix:script", 26 | "lint-fix:script": "npm run lint:script -- --fix", 27 | "lint:script": "eslint", 28 | "format": "prettier --write \"src/**/*.{js,ts,jsx,tsx,astro}\"", 29 | "sync-latest": "./scripts/sync-latest-blog.sh" 30 | }, 31 | "dependencies": { 32 | "@astrojs/check": "^0.9.4", 33 | "@astrojs/mdx": "^4.0.8", 34 | "@astrojs/react": "^4.2.0", 35 | "@astrojs/rss": "^4.0.11", 36 | "@astrojs/sitemap": "^3.2.1", 37 | "@docsearch/css": "^3.6.2", 38 | "@docsearch/react": "^3.6.2", 39 | "@radix-ui/colors": "^3.0.0", 40 | "@tailwindcss/vite": "^4.0.3", 41 | "@types/node": "^22.8.4", 42 | "@types/react": "^18.3.12", 43 | "@types/react-dom": "^18.3.1", 44 | "astro": "^5.2.5", 45 | "astro-expressive-code": "^0.37.1", 46 | "classnames": "^2.5.1", 47 | "dayjs": "^1.11.13", 48 | "i18next": "^24.2.2", 49 | "mdast-util-to-string": "^4.0.0", 50 | "react": "^18.3.1", 51 | "react-dom": "^18.3.1", 52 | "reading-time": "^1.5.0", 53 | "rehype-figure": "^1.0.1", 54 | "rehype-katex": "^7.0.1", 55 | "remark-block-containers": "^1.1.0", 56 | "remark-code-import": "^1.2.0", 57 | "remark-gemoji": "^8.0.0", 58 | "remark-math": "^6.0.0", 59 | "sharp": "^0.33.5", 60 | "tailwindcss": "^4.0.3", 61 | "typescript": "^5.6.3", 62 | "vite-plugin-svgr": "^4.2.0" 63 | }, 64 | "devDependencies": { 65 | "@commitlint/cli": "^19.5.0", 66 | "@commitlint/config-angular": "^19.5.0", 67 | "@eslint/js": "9.14.0", 68 | "@typescript-eslint/parser": "^8.12.2", 69 | "eslint": "9.14.0", 70 | "eslint-config-prettier": "^9.1.0", 71 | "eslint-plugin-astro": "^1.3.0", 72 | "eslint-plugin-import": "^2.31.0", 73 | "eslint-plugin-jsx-a11y": "^6.10.2", 74 | "eslint-plugin-react": "^7.37.2", 75 | "globals": "^15.11.0", 76 | "husky": "^9.1.6", 77 | "postcss-import": "^16.1.0", 78 | "prettier": "^3.3.3", 79 | "prettier-plugin-astro": "^0.14.1", 80 | "prettier-plugin-tailwindcss": "^0.6.11", 81 | "sanitize-html": "^2.14.0", 82 | "typescript-eslint": "^8.12.2" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /plugins/remark-modified-time.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: Automatically add last modified time 3 | */ 4 | import { execSync } from 'child_process'; 5 | 6 | export function remarkModifiedTime() { 7 | return function (tree, file) { 8 | const filepath = file.history[0]; 9 | const result = execSync(`git log -1 --pretty="format:%cI" "${filepath}"`); 10 | file.data.astro.frontmatter.lastModified = result.toString(); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /plugins/remark-reading-time.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: Add reading time 3 | */ 4 | import getReadingTime from 'reading-time'; 5 | import { toString } from 'mdast-util-to-string'; 6 | 7 | export function remarkReadingTime() { 8 | return function (tree, { data }) { 9 | const textOnPage = toString(tree); 10 | const readingTime = getReadingTime(textOnPage); 11 | // data.astro.frontmatter.minutesRead = i18next.t('blog.readingTime', { 12 | // minutes: Math.round(readingTime.minutes), 13 | // }); 14 | data.astro.frontmatter.minutesRead = Math.round(readingTime.minutes); 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /public/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SlateDesign/slate-blog/3971289073bf2b9f76f47a00df36b7da2314a898/public/avatar.png -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/og_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SlateDesign/slate-blog/3971289073bf2b9f76f47a00df36b7da2314a898/public/og_image.png -------------------------------------------------------------------------------- /scripts/sync-latest-blog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # slate-blog github 3 | SLATE_BLOG_REPO="https://github.com/SlateDesign/slate-blog.git" 4 | 5 | if ! git remote | grep -q "^slate_blog$"; then 6 | git remote add slate_blog "$SLATE_BLOG_REPO" || { echo "Failed to add Slate Blog remote repository"; exit 1; } 7 | 8 | echo "Successfully added Slate Blog remote repository" 9 | fi 10 | 11 | git fetch --no-tags slate_blog || { echo "Failed to fetch code from Slate Blog"; exit 1; } 12 | 13 | git merge slate_blog/main --allow-unrelated-histories 14 | if [ $? -ne 0 ]; then 15 | echo "Merge conflicts detected. Please resolve conflicts manually and run the following commands:" 16 | echo "1. After resolving conflicts, use 'git add ' to mark conflicts as resolved" 17 | echo "2. Run 'git commit' to complete the merge" 18 | exit 1 19 | fi 20 | 21 | git remote remove slate_blog || { echo "Failed to remove Slate Blog remote repository"; exit 1; } 22 | 23 | echo "Merge completed" 24 | -------------------------------------------------------------------------------- /slate.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file Theme configuration 3 | */ 4 | import { defineConfig } from './src/helpers/config-helper'; 5 | 6 | export default defineConfig({ 7 | lang: 'en-US', 8 | site: 'https://slate-blog-demo.vercel.app', 9 | avatar: '/avatar.png', 10 | title: 'Slate Blog', 11 | description: 'Pure thoughts, simple stories.', 12 | lastModified: true, 13 | readTime: true, 14 | footer: { 15 | copyright: '© 2025 Slate Design', 16 | }, 17 | socialLinks: [ 18 | { 19 | icon: 'github', 20 | link: 'https://github.com/SlateDesign/slate-blog' 21 | }, 22 | ] 23 | }); -------------------------------------------------------------------------------- /src/assets/images/desktop-outlined.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/dribbble.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/figma.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/in.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/instagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/mail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/moon-outlined.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/notion.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/rss.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/images/sun-outlined.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/threads.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/youtube.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/style/blog.css: -------------------------------------------------------------------------------- 1 | @reference "./common.css"; 2 | 3 | .blog-content { 4 | @apply mb-16; 5 | 6 | h1 { 7 | @apply text-slate12 mb-2 text-4xl leading-normal font-bold; 8 | } 9 | h2 { 10 | @apply text-slate12 mt-8 mb-2 text-3xl leading-normal font-semibold; 11 | } 12 | h3 { 13 | @apply text-slate12 my-2 text-2xl leading-normal font-semibold; 14 | } 15 | 16 | h4 { 17 | @apply text-slate12 my-2 text-base leading-normal font-semibold; 18 | } 19 | 20 | p { 21 | @apply text-slate12 my-6 mt-4 text-base leading-relaxed; 22 | } 23 | 24 | a { 25 | @apply text-indigo11 decoration-indigo6 underline-offset-4 transition-all hover:underline; 26 | } 27 | 28 | /* a::after{ 29 | @apply text-slate8 mx-0.5; 30 | content: '↗'; 31 | } */ 32 | 33 | img { 34 | @apply border-slate6 mx-auto my-8 w-full rounded-2xl border; 35 | } 36 | 37 | code { 38 | @apply border-slate4 bg-slate3 text-slate11 mx-0.5 rounded-lg border p-0.5 px-1 text-base; 39 | } 40 | 41 | .code { 42 | @apply text-wrap; 43 | } 44 | 45 | .katex-html { 46 | @apply hidden; 47 | } 48 | 49 | .block-default { 50 | @apply my-6 rounded-xl p-4; 51 | 52 | p { 53 | @apply text-slate12 my-0 text-base leading-relaxed; 54 | } 55 | } 56 | 57 | .tip { 58 | @apply border-indigo4 bg-indigo2 border; 59 | 60 | .block-title { 61 | @apply text-indigo11 text-lg font-semibold; 62 | } 63 | } 64 | 65 | .warning { 66 | @apply border-orange4 bg-orange2 border; 67 | 68 | .block-title { 69 | @apply text-orange11 text-lg font-semibold; 70 | } 71 | } 72 | 73 | table { 74 | @apply ring-slate4 my-6 w-full border-collapse overflow-hidden rounded-xl ring-1; 75 | 76 | thead { 77 | @apply border-slate4 bg-slate3 border-b text-left; 78 | } 79 | 80 | th, 81 | td { 82 | @apply border-slate4 border-b px-4 py-3; 83 | } 84 | } 85 | 86 | blockquote { 87 | @apply border-slate4 my-6 border-l-2 pl-4; 88 | 89 | p { 90 | @apply text-slate10 text-base leading-relaxed; 91 | } 92 | } 93 | 94 | hr { 95 | @apply border-slate4 mx-auto my-12 w-1/3; 96 | } 97 | 98 | ul { 99 | @apply text-slate11 mt-2 mb-6 list-disc pl-4 text-base leading-relaxed; 100 | } 101 | 102 | .task-list-item { 103 | @apply list-none; 104 | } 105 | 106 | ul li::marker { 107 | @apply text-slate8; 108 | } 109 | 110 | ol { 111 | @apply text-slate11 mt-2 mb-6 list-decimal pl-6 text-base leading-relaxed; 112 | } 113 | 114 | li { 115 | @apply text-slate11 text-base leading-relaxed; 116 | } 117 | 118 | ol li::marker { 119 | @apply text-slate8; 120 | } 121 | 122 | figcaption { 123 | @apply text-slate8 -mt-4 text-center text-sm; 124 | } 125 | 126 | .expressive-code { 127 | @apply mb-6; 128 | } 129 | 130 | heti-spacing { 131 | @apply inline; 132 | } 133 | 134 | .heti-spacing-start { 135 | @apply me-[0.25em]; 136 | } 137 | 138 | .heti-spacing-end { 139 | @apply ms-[0.25em]; 140 | } 141 | 142 | heti-adjacent { 143 | @apply inline; 144 | } 145 | 146 | .heti-adjacent-half { 147 | @apply me-[-0.5em]; 148 | } 149 | 150 | .heti-adjacent-quarter { 151 | @apply me-[-0.25em]; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/assets/style/common.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "@radix-ui/colors/gray.css"; 3 | @import "@radix-ui/colors/blue.css"; 4 | @import "@radix-ui/colors/red.css"; 5 | @import "@radix-ui/colors/green.css"; 6 | @import "@radix-ui/colors/slate.css"; 7 | @import "@radix-ui/colors/tomato.css"; 8 | @import "@radix-ui/colors/amber.css"; 9 | @import "@radix-ui/colors/indigo.css"; 10 | @import "@radix-ui/colors/orange.css"; 11 | @import "@radix-ui/colors/gray-dark.css"; 12 | @import "@radix-ui/colors/blue-dark.css"; 13 | @import "@radix-ui/colors/red-dark.css"; 14 | @import "@radix-ui/colors/green-dark.css"; 15 | @import "@radix-ui/colors/slate-dark.css"; 16 | @import "@radix-ui/colors/tomato-dark.css"; 17 | @import "@radix-ui/colors/amber-dark.css"; 18 | @import "@radix-ui/colors/indigo-dark.css"; 19 | @import "@radix-ui/colors/orange-dark.css"; 20 | 21 | @custom-variant dark (&:where(.dark, .dark *)); 22 | 23 | @theme { 24 | --color-gray1: var(--gray-1); 25 | --color-gray2: var(--gray-2); 26 | --color-gray3: var(--gray-3); 27 | --color-gray4: var(--gray-4); 28 | --color-gray5: var(--gray-5); 29 | --color-gray6: var(--gray-6); 30 | --color-gray7: var(--gray-7); 31 | --color-gray8: var(--gray-8); 32 | --color-gray9: var(--gray-9); 33 | --color-gray10: var(--gray-10); 34 | --color-gray11: var(--gray-11); 35 | --color-gray12: var(--gray-12); 36 | --color-slate1: var(--slate-1); 37 | --color-slate2: var(--slate-2); 38 | --color-slate3: var(--slate-3); 39 | --color-slate4: var(--slate-4); 40 | --color-slate5: var(--slate-5); 41 | --color-slate6: var(--slate-6); 42 | --color-slate7: var(--slate-7); 43 | --color-slate8: var(--slate-8); 44 | --color-slate9: var(--slate-9); 45 | --color-slate10: var(--slate-10); 46 | --color-slate11: var(--slate-11); 47 | --color-slate12: var(--slate-12); 48 | --color-blue1: var(--blue-1); 49 | --color-blue2: var(--blue-2); 50 | --color-blue3: var(--blue-3); 51 | --color-blue4: var(--blue-4); 52 | --color-blue5: var(--blue-5); 53 | --color-blue6: var(--blue-6); 54 | --color-blue7: var(--blue-7); 55 | --color-blue8: var(--blue-8); 56 | --color-blue9: var(--blue-9); 57 | --color-blue10: var(--blue-10); 58 | --color-blue11: var(--blue-11); 59 | --color-blue12: var(--blue-12); 60 | --color-red1: var(--red-1); 61 | --color-red2: var(--red-2); 62 | --color-red3: var(--red-3); 63 | --color-red4: var(--red-4); 64 | --color-red5: var(--red-5); 65 | --color-red6: var(--red-6); 66 | --color-red7: var(--red-7); 67 | --color-red8: var(--red-8); 68 | --color-red9: var(--red-9); 69 | --color-red10: var(--red-10); 70 | --color-red11: var(--red-11); 71 | --color-red12: var(--red-12); 72 | --color-green1: var(--green-1); 73 | --color-green2: var(--green-2); 74 | --color-green3: var(--green-3); 75 | --color-green4: var(--green-4); 76 | --color-green5: var(--green-5); 77 | --color-green6: var(--green-6); 78 | --color-green7: var(--green-7); 79 | --color-green8: var(--green-8); 80 | --color-green9: var(--green-9); 81 | --color-green10: var(--green-10); 82 | --color-green11: var(--green-11); 83 | --color-green12: var(--green-12); 84 | --color-tomato1: var(--tomato-1); 85 | --color-tomato2: var(--tomato-2); 86 | --color-tomato3: var(--tomato-3); 87 | --color-tomato4: var(--tomato-4); 88 | --color-tomato5: var(--tomato-5); 89 | --color-tomato6: var(--tomato-6); 90 | --color-tomato7: var(--tomato-7); 91 | --color-tomato8: var(--tomato-8); 92 | --color-tomato9: var(--tomato-9); 93 | --color-tomato10: var(--tomato-10); 94 | --color-tomato11: var(--tomato-11); 95 | --color-tomato12: var(--tomato-12); 96 | --color-amber1: var(--amber-1); 97 | --color-amber2: var(--amber-2); 98 | --color-amber3: var(--amber-3); 99 | --color-amber4: var(--amber-4); 100 | --color-amber5: var(--amber-5); 101 | --color-amber6: var(--amber-6); 102 | --color-amber7: var(--amber-7); 103 | --color-amber8: var(--amber-8); 104 | --color-amber9: var(--amber-9); 105 | --color-amber10: var(--amber-10); 106 | --color-amber11: var(--amber-11); 107 | --color-amber12: var(--amber-12); 108 | --color-indigo1: var(--indigo-1); 109 | --color-indigo2: var(--indigo-2); 110 | --color-indigo3: var(--indigo-3); 111 | --color-indigo4: var(--indigo-4); 112 | --color-indigo5: var(--indigo-5); 113 | --color-indigo6: var(--indigo-6); 114 | --color-indigo7: var(--indigo-7); 115 | --color-indigo8: var(--indigo-8); 116 | --color-indigo9: var(--indigo-9); 117 | --color-indigo10: var(--indigo-10); 118 | --color-indigo11: var(--indigo-11); 119 | --color-indigo12: var(--indigo-12); 120 | --color-orange1: var(--orange-1); 121 | --color-orange2: var(--orange-2); 122 | --color-orange3: var(--orange-3); 123 | --color-orange4: var(--orange-4); 124 | --color-orange5: var(--orange-5); 125 | --color-orange6: var(--orange-6); 126 | --color-orange7: var(--orange-7); 127 | --color-orange8: var(--orange-8); 128 | --color-orange9: var(--orange-9); 129 | --color-orange10: var(--orange-10); 130 | --color-orange11: var(--orange-11); 131 | --color-orange12: var(--orange-12); 132 | 133 | --container-180: 45rem; 134 | --container-220: 55rem; 135 | 136 | --padding-18: 4.5rem; 137 | 138 | --transition-property-left: left; 139 | } 140 | 141 | body { 142 | @apply text-sm text-slate12; 143 | } 144 | -------------------------------------------------------------------------------- /src/components/affix-title/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | export interface AffixTitleProps { 5 | /** 距离窗口顶部达到指定偏移量后触发, 默认 320 */ 6 | offsetTop?: number; 7 | title: string; 8 | } 9 | 10 | const AffixTitle = (props: AffixTitleProps) => { 11 | const { title, offsetTop = 320 } = props; 12 | const affixTitleRef = useRef(null); 13 | const [isVisible, setIsVisible] = useState(false); 14 | 15 | const classes = classNames( 16 | 'fixed left-0 right-0 top-0 w-full transform bg-slate1/90 backdrop-blur-md transition-all duration-300 ease-in-out z-10', 17 | isVisible ? ['translate-y-0', 'opacity-100'] : ['-translate-y-full', 'opacity-0'], 18 | ); 19 | 20 | const handleScroll = () => { 21 | const scrollTop = document.documentElement.scrollTop; 22 | setIsVisible(scrollTop >= offsetTop); 23 | }; 24 | 25 | useEffect(() => { 26 | handleScroll(); 27 | window.addEventListener('scroll', handleScroll); 28 | 29 | return () => { 30 | window.removeEventListener('scroll', handleScroll); 31 | }; 32 | }, []); 33 | 34 | return ( 35 |
39 |
40 | 46 |
{title}
47 |
48 |
49 |
50 | ); 51 | }; 52 | 53 | export default AffixTitle; 54 | -------------------------------------------------------------------------------- /src/components/button/index.css: -------------------------------------------------------------------------------- 1 | @reference '../../assets/style/common.css'; 2 | 3 | .sb-button { 4 | @apply touch-manipulation select-none rounded-full px-4 py-2 text-base bg-slate3 text-slate11 hover:bg-slate4 hover:text-slate12 transition-all duration-200 ease-in-out min-w-16 active:scale-95 cursor-pointer; 5 | } 6 | 7 | .sb-button-default { 8 | @apply bg-slate3; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/button/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import classNames from 'classnames'; 3 | import './index.css'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | const ButtonTypes = ['default', 'link'] as const; 7 | export type ButtonType = (typeof ButtonTypes)[number]; 8 | 9 | export interface BaseButtonProps { 10 | /** 设置按钮类型, 默认 default */ 11 | type?: ButtonType; 12 | className?: string; 13 | /** 将按钮宽度调整为其父宽度 */ 14 | block?: boolean; 15 | children?: ReactNode; 16 | /** 禁用 */ 17 | disabled?: boolean; 18 | [key: `data-${string}`]: string; 19 | onClick?: React.MouseEventHandler; 20 | } 21 | 22 | type MergedHTMLAttributes = Omit< 23 | React.HTMLAttributes & 24 | React.ButtonHTMLAttributes & 25 | React.AnchorHTMLAttributes, 26 | 'type' | 'color' 27 | >; 28 | 29 | export interface ButtonProps extends BaseButtonProps, MergedHTMLAttributes { 30 | /** 点击跳转的地址 */ 31 | href?: string; 32 | /** 相当于 a 链接的 target 属性,href 存在时生效 */ 33 | target?: string; 34 | } 35 | 36 | const Button = (props: ButtonProps) => { 37 | const { 38 | type = 'default', 39 | children, 40 | className, 41 | disabled = false, 42 | block = false, 43 | onClick, 44 | ...rest 45 | } = props; 46 | 47 | const classes = classNames( 48 | 'sb-button', 49 | { 50 | block: block, 51 | 'inline-block': !block, 52 | [`sb-button-${type}`]: type, 53 | }, 54 | className, 55 | ); 56 | 57 | const handleClick = ( 58 | e: React.MouseEvent, 59 | ) => { 60 | if (disabled) { 61 | e.preventDefault(); 62 | return; 63 | } 64 | ( 65 | onClick as React.MouseEventHandler 66 | )?.(e); 67 | }; 68 | 69 | if (rest.href) { 70 | return ( 71 | 78 | {children} 79 | 80 | ); 81 | } 82 | 83 | return ( 84 | 87 | ); 88 | }; 89 | 90 | export default Button; 91 | -------------------------------------------------------------------------------- /src/components/code-group-event/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useCodeGroups } from 'remark-block-containers/useCodeGroups'; 3 | 4 | const CodeGroupEvent = () => { 5 | useEffect(() => { 6 | useCodeGroups(); 7 | }, [useCodeGroups]); 8 | 9 | return <>; 10 | }; 11 | 12 | export default CodeGroupEvent; 13 | -------------------------------------------------------------------------------- /src/components/json-ld/article.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import slateConfig from '~@/slate.config'; 3 | interface Props { 4 | title: string; 5 | description?: string; 6 | image?: string; 7 | pubDate?: Date; 8 | lastModified: Date; 9 | author?: string; 10 | } 11 | const { 12 | title, 13 | description = slateConfig.description, 14 | image = '/og_image.png', 15 | pubDate, 16 | lastModified, 17 | author = 'slate', 18 | } = Astro.props; 19 | const jsonLd = JSON.stringify({ 20 | '@context': 'https://schema.org', 21 | '@type': 'BlogPosting', 22 | headline: title, 23 | description: description, 24 | image: new URL(image, Astro.url), 25 | datePublished: pubDate?.toISOString(), 26 | dateModified: lastModified?.toISOString(), 27 | author: { 28 | '@type': 'Person', 29 | name: author, 30 | }, 31 | }); 32 | --- 33 | 34 | 51 | 52 | 53 |
54 | 55 |
56 | 57 | 58 | 59 |
60 | 61 |
62 | 63 | 64 | -------------------------------------------------------------------------------- /src/components/search/index.css: -------------------------------------------------------------------------------- 1 | @reference '../../assets/style/common.css'; 2 | 3 | .DocSearch-Button { 4 | --docsearch-searchbox-background: var(--color-slate3); 5 | --docsearch-searchbox-focus-background: var(--color-slate4); 6 | --docsearch-muted-color: var(--color-slate10); 7 | --docsearch-searchbox-shadow: none; 8 | @apply flex items-center m-0 h-10 transition-all duration-200; 9 | padding: 0 16px; 10 | 11 | .DocSearch-Button-Placeholder { 12 | @apply hidden; 13 | } 14 | 15 | .DocSearch-Search-Icon { 16 | @apply h-4 text-slate10; 17 | } 18 | 19 | .DocSearch-Button-Keys { 20 | @apply min-w-auto ml-2; 21 | } 22 | 23 | .DocSearch-Button-Key { 24 | @apply top-0 mr-0 shadow-none first-of-type:pt-px first-of-type:text-xl first-of-type:after:content-['+'] first-of-type:after:relative first-of-type:after:top-[-1px] first-of-type:after:left-0.5 first-of-type:after:text-base; 25 | background: transparent; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { DocSearch } from '@docsearch/react'; 2 | import slateConfig from '~@/slate.config'; 3 | import { useLocales } from './useLocales'; 4 | import '@docsearch/css'; 5 | import './index.css'; 6 | 7 | interface SearchProps { 8 | className?: string; 9 | } 10 | 11 | const Search = ({ className }: SearchProps) => { 12 | const algoliaLocalesConfig = useLocales(slateConfig.lang); 13 | 14 | return ( 15 | slateConfig.algolia && ( 16 |
17 | 24 |
25 | ) 26 | ); 27 | }; 28 | 29 | export default Search; 30 | -------------------------------------------------------------------------------- /src/components/search/useLocales.ts: -------------------------------------------------------------------------------- 1 | import type { DocSearchProps } from '@docsearch/react'; 2 | import { useMemo } from 'react'; 3 | import { languages, type LangType } from '@/typings/config'; 4 | 5 | export const algoliaLocalesConfig: Record< 6 | LangType, 7 | Omit 8 | > = { 9 | 'en-US': {}, 10 | 'zh-CN': { 11 | placeholder: '搜索文档', 12 | translations: { 13 | button: { 14 | buttonText: '搜索', 15 | buttonAriaLabel: '搜索', 16 | }, 17 | modal: { 18 | searchBox: { 19 | resetButtonTitle: '清除查询条件', 20 | resetButtonAriaLabel: '清除查询条件', 21 | cancelButtonText: '取消', 22 | cancelButtonAriaLabel: '取消', 23 | }, 24 | startScreen: { 25 | recentSearchesTitle: '搜索历史', 26 | noRecentSearchesText: '没有搜索历史', 27 | saveRecentSearchButtonTitle: '保存至搜索历史', 28 | removeRecentSearchButtonTitle: '从搜索历史中移除', 29 | favoriteSearchesTitle: '收藏', 30 | removeFavoriteSearchButtonTitle: '从收藏中移除', 31 | }, 32 | errorScreen: { 33 | titleText: '无法获取结果', 34 | helpText: '你可能需要检查你的网络连接', 35 | }, 36 | footer: { 37 | selectText: '选择', 38 | navigateText: '切换', 39 | closeText: '关闭', 40 | searchByText: '搜索提供者', 41 | }, 42 | noResultsScreen: { 43 | noResultsText: '无法找到相关结果', 44 | suggestedQueryText: '你可以尝试查询', 45 | reportMissingResultsText: '你认为该查询应该有结果?', 46 | reportMissingResultsLinkText: '点击反馈', 47 | }, 48 | }, 49 | }, 50 | }, 51 | }; 52 | 53 | export function useLocales(local: LangType = languages[0]) { 54 | const config = useMemo(() => { 55 | return algoliaLocalesConfig[local]; 56 | }, [local]); 57 | 58 | return config; 59 | } 60 | -------------------------------------------------------------------------------- /src/components/social-links/index.css: -------------------------------------------------------------------------------- 1 | .sb-social-icon { 2 | mask: var(--icon) no-repeat; 3 | mask-size: 100% 100%; 4 | @apply block h-full w-full bg-current fill-current text-inherit; 5 | 6 | &.facebook { 7 | --icon: url('../../assets/images/facebook.svg'); 8 | } 9 | 10 | &.github { 11 | --icon: url('../../assets/images/github.svg'); 12 | } 13 | 14 | &.instagram { 15 | --icon: url('../../assets/images/instagram.svg'); 16 | } 17 | 18 | /* &.npm { 19 | --icon: url('../../assets/images/github.svg'); 20 | } 21 | 22 | &.jike { 23 | --icon: url('../../assets/images/github.svg'); 24 | } 25 | 26 | &.rednote { 27 | --icon: url('../../assets/images/github.svg'); 28 | } */ 29 | 30 | &.threads { 31 | --icon: url('../../assets/images/threads.svg'); 32 | } 33 | 34 | /* &.stackoverflow { 35 | --icon: url('../../assets/images/github.svg'); 36 | } 37 | 38 | &.weibo { 39 | --icon: url('../../assets/images/github.svg'); 40 | } */ 41 | 42 | &.x { 43 | --icon: url('../../assets/images/x.svg'); 44 | } 45 | 46 | &.youtube { 47 | --icon: url('../../assets/images/youtube.svg'); 48 | } 49 | 50 | &.dribbble { 51 | --icon: url('../../assets/images/dribbble.svg'); 52 | } 53 | 54 | &.notion { 55 | --icon: url('../../assets/images/notion.svg'); 56 | } 57 | 58 | &.link { 59 | --icon: url('../../assets/images/link.svg'); 60 | } 61 | 62 | &.figma { 63 | --icon: url('../../assets/images/figma.svg'); 64 | } 65 | 66 | &.rss { 67 | --icon: url('../../assets/images/rss.svg'); 68 | } 69 | 70 | &.mail { 71 | --icon: url('../../assets/images/mail.svg'); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/components/social-links/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, type CSSProperties } from 'react'; 2 | import classNames from 'classnames'; 3 | import slateConfig from '~@/slate.config'; 4 | import type { SocialLink } from '@/typings/config'; 5 | import './index.css'; 6 | 7 | export interface SocialLinksProps { 8 | className?: string; 9 | list?: SocialLink[]; 10 | } 11 | 12 | interface SocialIconProps { 13 | className?: string; 14 | style?: CSSProperties; 15 | } 16 | 17 | function SocialIcon({ className, style }: SocialIconProps) { 18 | const classes = classNames('sb-social-icon', className); 19 | return ; 20 | } 21 | 22 | function SocialLinks(props: SocialLinksProps) { 23 | const { className, list = slateConfig.socialLinks ?? [] } = props; 24 | 25 | const classes = classNames('flex items-center gap-6 flex-wrap', className); 26 | 27 | const socialLinks = useMemo(() => { 28 | return list.map((item) => { 29 | let icon; 30 | if (typeof item.icon !== 'string') { 31 | const iconBase64 = `data:image/svg+xml;base64,${btoa(item.icon.svg)}`; 32 | // @ts-expect-error ''--icon'' does not exist in type CSSProperties 33 | icon = ; 34 | } else { 35 | icon = ; 36 | } 37 | 38 | return { 39 | ...item, 40 | icon, 41 | }; 42 | }); 43 | }, [list]); 44 | 45 | return ( 46 |
47 | {socialLinks.map((socialLink, index) => ( 48 | 56 | {socialLink.icon} 57 | 58 | ))} 59 |
60 | ); 61 | } 62 | 63 | export default SocialLinks; 64 | -------------------------------------------------------------------------------- /src/components/theme-select/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState, useEffect, type ReactNode } from 'react'; 2 | import DesktopOutlined from '@/assets/images/desktop-outlined.svg?react'; 3 | import SunOutlined from '@/assets/images/sun-outlined.svg?react'; 4 | import MoonOutlined from '@/assets/images/moon-outlined.svg?react'; 5 | import stateConfig from '~@/slate.config'; 6 | import { setThemeMode } from '@/helpers/utils'; 7 | import type { ThemeOptions } from '@/typings/config'; 8 | import { ThemeValue } from '@/typings/global'; 9 | 10 | const THEME_LIST: Array<{ icon: ReactNode; value: ThemeValue }> = [ 11 | { 12 | icon: , 13 | value: ThemeValue.Auto, 14 | }, 15 | { 16 | icon: , 17 | value: ThemeValue.Light, 18 | }, 19 | { 20 | icon: , 21 | value: ThemeValue.Dark, 22 | }, 23 | ]; 24 | 25 | const ThemeSelect = () => { 26 | const [currentTheme, setCurrentTheme] = useState(); 27 | 28 | useEffect(() => { 29 | const theme = localStorage.getItem('theme'); 30 | if ( 31 | theme === ThemeValue.Light || 32 | theme === ThemeValue.Dark || 33 | theme === ThemeValue.Auto 34 | ) { 35 | setCurrentTheme(theme); 36 | return; 37 | } 38 | const presetTheme = (stateConfig.theme as ThemeOptions).mode; 39 | setCurrentTheme(presetTheme as ThemeValue); 40 | }, []); 41 | 42 | const themeSelectClasses = useMemo(() => { 43 | if (currentTheme === ThemeValue.Dark) { 44 | return 'left-[58px]'; 45 | } else if (currentTheme === ThemeValue.Light) { 46 | return 'left-[30px]'; 47 | } else { 48 | return 'left-0.5'; 49 | } 50 | }, [currentTheme]); 51 | 52 | const handleThemeChange = (value: ThemeValue) => { 53 | setCurrentTheme(value); 54 | 55 | let mode = value; 56 | if (value === ThemeValue.Auto) { 57 | mode = window.matchMedia('(prefers-color-scheme: dark)').matches 58 | ? ThemeValue.Dark 59 | : ThemeValue.Light; 60 | } 61 | localStorage.setItem('theme', value); 62 | setThemeMode(mode); 63 | }; 64 | 65 | return ( 66 |
70 |
73 | {THEME_LIST.map((item) => ( 74 |
handleThemeChange(item.value)} 78 | onKeyDown={(e) => { 79 | if (e.key === 'Enter' || e.key === ' ') { 80 | handleThemeChange(item.value); 81 | } 82 | }} 83 | tabIndex={0} 84 | role="radio" 85 | aria-label={item.value} 86 | aria-checked={item.value === currentTheme} 87 | > 88 | {item.icon} 89 |
90 | ))} 91 |
92 | ); 93 | }; 94 | 95 | export default ThemeSelect; 96 | -------------------------------------------------------------------------------- /src/components/toc/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: kim 3 | * @Description: 目录 4 | */ 5 | import type { MarkdownHeading } from 'astro'; 6 | import classNames from 'classnames'; 7 | 8 | interface TocProps { 9 | className?: string; 10 | listClassName?: string; 11 | dataSource?: MarkdownHeading[]; 12 | } 13 | 14 | function Toc(props: TocProps) { 15 | const { dataSource = [], className, listClassName } = props; 16 | const listClasses = classNames('text-slate8', listClassName); 17 | 18 | return ( 19 | !!dataSource.length && ( 20 |
21 | 40 |
41 | ) 42 | ); 43 | } 44 | 45 | export default Toc; 46 | -------------------------------------------------------------------------------- /src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection, z } from 'astro:content'; 2 | 3 | const postCollection = defineCollection({ 4 | type: 'content', 5 | schema: z 6 | .object({ 7 | /** Title */ 8 | title: z.string(), 9 | /** Description */ 10 | description: z.string().optional(), 11 | /** Tags */ 12 | tags: z.array(z.string()).optional(), 13 | /** Whether it's a draft */ 14 | draft: z.boolean().optional(), 15 | /** Publish date (required when not draft) */ 16 | pubDate: z.coerce.date().optional(), 17 | }) 18 | .refine( 19 | (data) => { 20 | // If it is a draft, then pubDate is not required; otherwise, it is mandatory. 21 | if (data.draft === true) { 22 | return true; 23 | } 24 | return data.pubDate !== undefined; 25 | }, 26 | { 27 | message: 'When draft is false, publicDate is required', 28 | path: ['publicDate'], 29 | }, 30 | ), 31 | }); 32 | 33 | export const collections = { post: postCollection }; 34 | -------------------------------------------------------------------------------- /src/content/post/40-questions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 40 questions 3 | description: This repo maintains revisons and translations to the list of 40 questions I ask myself each year and each decade. 4 | tags: 5 | - Life 6 | - Thinking 7 | - Writing 8 | pubDate: 2022-01-14 9 | --- 10 | 11 | ## About 12 | 13 | This repo maintains revisions and translations to the list of 40 questions I ask myself each year and each decade. 14 | 15 | See related blog posts: 16 | 17 | - [40 questions to ask yourself every year](http://stephanango.com/40-questions) (October 2016) 18 | - [40 questions to ask yourself every decade](http://stephanango.com/40-questions-decade) (January 2022) 19 | 20 | ## Translations 21 | 22 | If you'd like to help translate the questions, you can submit your translation via pull request. Place the translated files into a folder named using the [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) two letter language code. See the [/translations](/translations) folder. 23 | 24 | | ISO 639-1 | Language | Year | Decade | 25 | | :-------- | :-------------------- | ------------------------------------- | --------------------------------------- | 26 | | en | English (original) | [link](year.md) | [link](decade.md) | 27 | | ar | Arabic | [link](/translations/ar/year.md) | [link](/translations/ar/decade.md) | 28 | | bg | Bulgarian | [link](/translations/bg/year.md) | [link](/translations/bg/decade.md) | 29 | | ca | Catalan | [link](/translations/ca/year.md) | [link](/translations/ca/decade.md) | 30 | | da | Danish | [link](/translations/da/year.md) | [link](/translations/da/decade.md) | 31 | | de | German | [link](/translations/de/year.md) | [link](/translations/de/decade.md) | 32 | | el | Greek | [link](/translations/el/year.md) | [link](/translations/el/decade.md) | 33 | | es | Spanish | [link](/translations/es/year.md) | [link](/translations/es/decade.md) | 34 | | fa | Persian | [link](/translations/fa/year.md) | [link](/translations/fa/decade.md) | 35 | | fi | Finnish | [link](/translations/fi/year.md) | [link](/translations/fi/decade.md) | 36 | | fr | French | [link](/translations/fr/year.md) | [link](/translations/fr/decade.md) | 37 | | hi | Hindi | [link](/translations/hi/year.md) | [link](/translations/hi/decade.md) | 38 | | hu | Hungarian (Magyar) | [link](/translations/hu/year.md) | [link](/translations/hu/decade.md) | 39 | | id | Indonesian | [link](/translations/id/year.md) | [link](/translations/id/decade.md) | 40 | | dv | Dhivehi | [link](/translations/dv/year.md) | [link](/translations/dv/decade.md) | 41 | | it | Italian | [link](/translations/it/year.md) | [link](/translations/it/decade.md) | 42 | | ja | Japanese | [link](/translations/ja/year.md) | [link](/translations/ja/decade.md) | 43 | | ko | Korean | [link](/translations/ko/year.md) | [link](/translations/ko/decade.md) | 44 | | lt | Lithuanian | [link](/translations/lt/year.md) | | 45 | | lv | Latvian | [link](/translations/lv/year.md) | [link](/translations/lv/decade.md) | 46 | | ml | Malayalam | [link](/translations/ml/year.md) | [link](/translations/ml/decade.md) | 47 | | nl | Dutch | [link](/translations/nl/year.md) | [link](/translations/nl/decade.md) | 48 | | no | Norwegian | [link](/translations/no/year.md) | [link](/translations/no/decade.md) | 49 | | pl | Polish | [link](/translations/pl/year.md) | [link](/translations/pl/decade.md) | 50 | | pt | Portuguese | [link](/translations/pt/year.md) | [link](/translations/pt/decade.md) | 51 | | ru | Russian | [link](/translations/ru/year.md) | [link](/translations/ru/decade.md) | 52 | | sv | Swedish | [link](/translations/sv/year.md) | [link](/translations/sv/decade.md) | 53 | | ta | Tamil | [link](/translations/ta/year.md) | [link](/translations/ta/decade.md) | 54 | | th | Thai | [link](/translations/th/year.md) | | 55 | | tl | Filipino | [link](/translations/tl/year.md) | | 56 | | tr | Turkish | [link](/translations/tr/year.md) | [link](/translations/tr/decade.md) | 57 | | uk | Ukrainian | [link](/translations/uk/year.md) | [link](/translations/uk/decade.md) | 58 | | vi | Vietnamese | [link](/translations/vi/year.md) | [link](/translations/vi/decade.md) | 59 | | zh-hans | Chinese (simplified) | [link](/translations/zh-hans/year.md) | [link](/translations/zh-hans/decade.md) | 60 | | zh-hant | Chinese (traditional) | [link](/translations/zh-hant/year.md) | [link](/translations/zh-hant/decade.md) | 61 | 62 | ## 40 questions to ask yourself each year 63 | 64 | One of my rituals at the end of each year is asking myself these forty questions. It usually takes me about a week to work my way through all of them. I find it to be one of the most valuable exercises to reflect on what happened, good and bad, and how I hope the year ahead will shape up. 65 | 66 | What is more interesting than each individual answer are the trends that emerge after years of answering the same questions. I’ve shared this list with my family and closest friends, and always enjoy discussing answers as we reflect on the year. 67 | 68 | Feel free to add or remove questions, and [share your edits with me](https://twitter.com/kepano). This is first and foremost a personal exercise, so make it a tradition you can enjoy for years to come. 69 | 70 | ## 40 questions to ask yourself each decade 71 | 72 | Some time ago I had answered [Proust's famous questionnaire](https://en.wikipedia.org/wiki/Proust_Questionnaire), and thought I would try answering it again. While the yearly questions help me reflect on what happened, Proust's questions are more about personal philosophies and traits, and thus change less frequently over time. 73 | 74 | Going through my answers to the Proust questionnaire, I was inspired to work on a new questionnaire that I could use for the next few decades. I tried create a set of questions that I would enjoy reflecting on in ten years. This list combines questions from Proust's questionnaire, and others I've been collecting ad hoc. 75 | 76 | It will be ten years before I can tell you whether this worked well or not, but join me on this journey if you'd like! Again, consider editing this list with questions you would like to know your own answers to in ten years. 77 | 78 | :::info 79 | From [40-questions](https://github.com/kepano/40-questions) 80 | ::: -------------------------------------------------------------------------------- /src/content/post/about-slate-blog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: About Slate Blog 3 | description: I use Obsidian to think, take notes, write essays, and publish this site. This is my bottom-up approach to note-taking and organizing things I am interested in. It embraces chaos and laziness to create emergent structure. 4 | tags: 5 | - Dev 6 | - Tailwind 7 | - Astro 8 | - Design 9 | pubDate: 2025-01-21 10 | --- 11 | 12 | ## Why We build it? 13 | We love writing and sharing, and we also appreciate great internet products. So we created this minimalist blogging product, focusing on content itself, providing a smooth and pure writing and reading experience, and built on the latest technology framework to make it faster and lighter. 14 | 15 | It also works seamlessly with [Obsidian](https://obsidian.md/), helping you turn your notes into published posts effortlessly. 16 | 17 | ## ✨ Features 18 | 19 | - Minimalist design theme 20 | - Mobile-first responsive layout 21 | - Light and dark mode support 22 | - Quick setup with zero configuration required 23 | - Draft mode with local preview and automatic production filtering 24 | - Built-in RSS feed with Follow authentication 25 | - Integrated Algolia search functionality 26 | - Comprehensive SEO optimization for better search rankings 27 | 28 | ## 🪜 Framework 29 | 30 | - Astro + React + Typescript 31 | - Tailwindcss + @radix-ui/colors 32 | - Updated to [Tailwind CSS v4.0](https://tailwindcss.com/blog/tailwindcss-v4) (Jan 10, 2025) 33 | - Docsearch 34 | 35 | ## 🔨 Usage 36 | 37 | ```bash 38 | # Start local server 39 | npm run dev 40 | # or 41 | yarn dev 42 | # or 43 | pnpm dev 44 | 45 | # Build 46 | npm run build 47 | # or 48 | yarn build 49 | # or 50 | pnpm build 51 | ``` 52 | 53 | ## 🗂 Directory Structure 54 | 55 | ``` 56 | - plugins/ # Custom plugins 57 | - src/ 58 | ├── assets/ # Asset files 59 | ├── components/ # Components 60 | ├── content/ # Content collections 61 | ├── helpers/ # Business logic 62 | ├── pages/ # Pages 63 | └── typings/ # Common types 64 | ``` 65 | 66 | > Articles are stored in the `src/content/post` directory, supporting markdown and mdx formats. The filename is the path name. For example, `src/content/post/my-first-post.md` => `https://your-blog.com/blog/my-first-post`. 67 | 68 | ## Configuration 69 | 70 | Theme configuration is done through `slate.config.ts` in the root directory. 71 | 72 | | Option | Description | Type | Default | 73 | | --- | --- | --- | --- | 74 | | site | Final deployment link | `string` | - | 75 | | title | Website title | `string` | - | 76 | | description | Website description | `string` | - | 77 | | lang | Language | `string` | `zh-CN` | 78 | | theme | Theme | `{ mode: 'auto' | 'light' | 'dark', enableUserChange: boolean }` | `{ mode: 'auto', enableUserChange: true }` | 79 | | avatar | Avatar | `string` | - | 80 | | sitemap | Website sitemap configuration | [SitemapOptions](https://docs.astro.build/en/guides/integrations-guide/sitemap/) | - | 81 | | readTime | Show reading time | `boolean` | `false` | 82 | | lastModified | Show last modified time | `boolean` | `false` | 83 | | algolia | Docsearch configuration | `{ appId: string, apiKey: string, indexName: string }` | - | 84 | | follow | Follow subscription authentication configuration | `{ feedId: string, userId: string }` | - | 85 | | footer | Website footer configuration | `{ copyright: string }` | - | 86 | 87 | ### Algolia Application 88 | 89 | 1. Deploy your site first 90 | 2. Apply for an `apiKey` at [algolia](https://docsearch.algolia.com/apply/) 91 | 3. After successful application, configure `algolia` in `slate.config.ts` 92 | 4. Redeploy 93 | 94 | ### Follow Subscription Authentication 95 | 96 | 1. Register a [follow](https://follow.is/) account 97 | 2. Deploy your site 98 | 3. Click the `+` button on `follow`, select `RSS` subscription, and enter the `rss` link (usually `[site]/rss.xml`, where `site` is the value of `site` in `slate.config.ts`) 99 | 4. Redeploy 100 | 101 | ## Article Frontmatter Description 102 | 103 | | Option | Description | Type | Required | 104 | | --- | --- | --- | --- | 105 | | title | Article title | `string` | Yes | 106 | | description | Article description | `string` | No | 107 | | tags | Article tags | `string[]` | No | 108 | | draft | Whether it's a draft. When not provided or `false`, `pubDate` must be provided; drafts are only visible in local preview | `boolean` | No | 109 | | pubDate | Article publication date | `date` | No, required when `draft` is `false` | 110 | 111 | **For more details, check the `src/content/config.ts` file** 112 | 113 | ### Example 114 | 115 | ```md 116 | --- 117 | title: 40 questions 118 | description: This repo maintains revisons and translations to the list of 40 questions I ask myself each year and each decade. 119 | tags: 120 | - Life 121 | - Thinking 122 | - Writing 123 | pubDate: 2025-01-06 124 | --- 125 | ``` 126 | 127 | ## Markdown Syntax Support 128 | 129 | In addition to standard Markdown syntax, the following extended syntax is supported: 130 | 131 | ### Basic Syntax 132 | - Headers, lists, blockquotes, code blocks and other basic syntax 133 | - Tables 134 | - Links and images 135 | - **Bold**, *italic*, and ~strikethrough~ text 136 | 137 | ### Extended Syntax 138 | #### Container syntax 139 | Using `:::` markers 140 | ```md 141 | :::info 142 | This is an information prompt 143 | ::: 144 | ``` 145 | The result will be displayed as: 146 | 147 | :::info 148 | This is an information prompt 149 | ::: 150 | 151 | #### LaTeX Mathematical Formulas 152 | - Inline formula: $E = mc^2$ 153 | - Block formula: $$ E = mc^2 $$ 154 | 155 | #### Support for image captions 156 | ```md 157 | ![Image caption](image-url) 158 | ``` 159 | The result will be displayed as: 160 | 161 | ![Slate Blog Preview](https://pub-acdbc21bc3964d18a684b0c51010a4e5.r2.dev/slate-blog-preview.png) 162 | 163 | ## Updates 164 | 165 | ### Version 1.3.0 166 | - Support Social Links 167 | - Optimize RSS article detail generation. 168 | - Add a script to synchronize the latest slate-blog version 169 | 170 | ### Version 1.2.0 171 | - Support i18n (English and Chinese) 172 | - Fixed known issues 173 | 174 | ### Version 1.1.1 175 | - Fixed known issues 176 | 177 | ### Version 1.1.0 178 | - Upgraded to support [Tailwind CSS v4.0](https://tailwindcss.com/blog/tailwindcss-v4) 179 | - Added dark mode support 180 | - Fixed known issues 181 | 182 | :::info 183 | From [Slate Blog](https://github.com/SlateDesign/slate-blog) 184 | ::: 185 | -------------------------------------------------------------------------------- /src/content/post/life-is-short.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: LIFE IS SHORT 3 | description: Life is short, as everyone knows. When I was a kid I used to wonder about this. Is life actually short, or are we really complaining about its finiteness? Would we be just as likely to feel life was short if we lived 10 times as long? 4 | tags: 5 | - Thinking 6 | - Life 7 | pubDate: 2016-01-01 8 | --- 9 | 10 | Life is short, as everyone knows. When I was a kid I used to wonder about this. Is life actually short, or are we really complaining about its finiteness? Would we be just as likely to feel life was short if we lived 10 times as long? 11 | 12 | Since there didn't seem any way to answer this question, I stopped wondering about it. Then I had kids. That gave me a way to answer the question, and the answer is that life actually is short. 13 | 14 | Having kids showed me how to convert a continuous quantity, time, into discrete quantities. You only get 52 weekends with your 2 year old. If Christmas-as-magic lasts from say ages 3 to 10, you only get to watch your child experience it 8 times. And while it's impossible to say what is a lot or a little of a continuous quantity like time, 8 is not a lot of something. If you had a handful of 8 peanuts, or a shelf of 8 books to choose from, the quantity would definitely seem limited, no matter what your lifespan was. 15 | 16 | Ok, so life actually is short. Does it make any difference to know that? 17 | 18 | It has for me. It means arguments of the form "Life is too short for x" have great force. It's not just a figure of speech to say that life is too short for something. It's not just a synonym for annoying. If you find yourself thinking that life is too short for something, you should try to eliminate it if you can. 19 | 20 | When I ask myself what I've found life is too short for, the word that pops into my head is "bullshit." I realize that answer is somewhat tautological. It's almost the definition of bullshit that it's the stuff that life is too short for. And yet bullshit does have a distinctive character. There's something fake about it. It's the junk food of experience. [1] 21 | 22 | If you ask yourself what you spend your time on that's bullshit, you probably already know the answer. Unnecessary meetings, pointless disputes, bureaucracy, posturing, dealing with other people's mistakes, traffic jams, addictive but unrewarding pastimes. 23 | 24 | There are two ways this kind of thing gets into your life: it's either forced on you, or it tricks you. To some extent you have to put up with the bullshit forced on you by circumstances. You need to make money, and making money consists mostly of errands. Indeed, the law of supply and demand ensures that: the more rewarding some kind of work is, the cheaper people will do it. It may be that less bullshit is forced on you than you think, though. There has always been a stream of people who opt out of the default grind and go live somewhere where opportunities are fewer in the conventional sense, but life feels more authentic. This could become more common. 25 | 26 | You can do it on a smaller scale without moving. The amount of time you have to spend on bullshit varies between employers. Most large organizations (and many small ones) are steeped in it. But if you consciously prioritize bullshit avoidance over other factors like money and prestige, you can probably find employers that will waste less of your time. 27 | 28 | If you're a freelancer or a small company, you can do this at the level of individual customers. If you fire or avoid toxic customers, you can decrease the amount of bullshit in your life by more than you decrease your income. 29 | 30 | But while some amount of bullshit is inevitably forced on you, the bullshit that sneaks into your life by tricking you is no one's fault but your own. And yet the bullshit you choose may be harder to eliminate than the bullshit that's forced on you. Things that lure you into wasting your time have to be really good at tricking you. An example that will be familiar to a lot of people is arguing online. When someone contradicts you, they're in a sense attacking you. Sometimes pretty overtly. Your instinct when attacked is to defend yourself. But like a lot of instincts, this one wasn't designed for the world we now live in. Counterintuitive as it feels, it's better most of the time not to defend yourself. Otherwise these people are literally taking your life. [2] 31 | 32 | Arguing online is only incidentally addictive. There are more dangerous things than that. As I've written before, one byproduct of technical progress is that things we like tend to become more addictive. Which means we will increasingly have to make a conscious effort to avoid addictions — to stand outside ourselves and ask "is this how I want to be spending my time?" 33 | 34 | As well as avoiding bullshit, one should actively seek out things that matter. But different things matter to different people, and most have to learn what matters to them. A few are lucky and realize early on that they love math or taking care of animals or writing, and then figure out a way to spend a lot of time doing it. But most people start out with a life that's a mix of things that matter and things that don't, and only gradually learn to distinguish between them. 35 | 36 | For the young especially, much of this confusion is induced by the artificial situations they find themselves in. In middle school and high school, what the other kids think of you seems the most important thing in the world. But when you ask adults what they got wrong at that age, nearly all say they cared too much what other kids thought of them. 37 | 38 | One heuristic for distinguishing stuff that matters is to ask yourself whether you'll care about it in the future. Fake stuff that matters usually has a sharp peak of seeming to matter. That's how it tricks you. The area under the curve is small, but its shape jabs into your consciousness like a pin. 39 | 40 | The things that matter aren't necessarily the ones people would call "important." Having coffee with a friend matters. You won't feel later like that was a waste of time. 41 | 42 | One great thing about having small children is that they make you spend time on things that matter: them. They grab your sleeve as you're staring at your phone and say "will you play with me?" And odds are that is in fact the bullshit-minimizing option. 43 | 44 | If life is short, we should expect its shortness to take us by surprise. And that is just what tends to happen. You take things for granted, and then they're gone. You think you can always write that book, or climb that mountain, or whatever, and then you realize the window has closed. The saddest windows close when other people die. Their lives are short too. After my mother died, I wished I'd spent more time with her. I lived as if she'd always be there. And in her typical quiet way she encouraged that illusion. But an illusion it was. I think a lot of people make the same mistake I did. 45 | 46 | The usual way to avoid being taken by surprise by something is to be consciously aware of it. Back when life was more precarious, people used to be aware of death to a degree that would now seem a bit morbid. I'm not sure why, but it doesn't seem the right answer to be constantly reminding oneself of the grim reaper hovering at everyone's shoulder. Perhaps a better solution is to look at the problem from the other end. Cultivate a habit of impatience about the things you most want to do. Don't wait before climbing that mountain or writing that book or visiting your mother. You don't need to be constantly reminding yourself why you shouldn't wait. Just don't wait. 47 | 48 | I can think of two more things one does when one doesn't have much of something: try to get more of it, and savor what one has. Both make sense here. 49 | 50 | How you live affects how long you live. Most people could do better. Me among them. 51 | 52 | But you can probably get even more effect by paying closer attention to the time you have. It's easy to let the days rush by. The "flow" that imaginative people love so much has a darker cousin that prevents you from pausing to savor life amid the daily slurry of errands and alarms. One of the most striking things I've read was not in a book, but the title of one: James Salter's Burning the Days. 53 | 54 | It is possible to slow time somewhat. I've gotten better at it. Kids help. When you have small children, there are a lot of moments so perfect that you can't help noticing. 55 | 56 | It does help too to feel that you've squeezed everything out of some experience. The reason I'm sad about my mother is not just that I miss her but that I think of all the things we could have done that we didn't. My oldest son will be 7 soon. And while I miss the 3 year old version of him, I at least don't have any regrets over what might have been. We had the best time a daddy and a 3 year old ever had. 57 | 58 | Relentlessly prune bullshit, don't wait to do things that matter, and savor the time you have. That's what you do when life is short. 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | Notes 67 | 68 | [1] At first I didn't like it that the word that came to mind was one that had other meanings. But then I realized the other meanings are fairly closely related. Bullshit in the sense of things you waste your time on is a lot like intellectual bullshit. 69 | 70 | [2] I chose this example deliberately as a note to self. I get attacked a lot online. People tell the craziest lies about me. And I have so far done a pretty mediocre job of suppressing the natural human inclination to say "Hey, that's not true!" 71 | 72 | **Thanks** to Jessica Livingston and Geoff Ralston for reading drafts of this. 73 | 74 | - [Korean Translation](https://blog.naver.com/happy_alpaca/221346692172) 75 | - [Japanese Translation](https://note.com/tokyojack/n/ne4c25e990634) 76 | - [Chinese Translation](https://www.jianshu.com/p/682429f8ac3f) 77 | 78 | 79 | :::info 80 | From [LIFE IS SHORT](https://www.paulgraham.com/vb.html) 81 | ::: -------------------------------------------------------------------------------- /src/content/post/obsidian-vault-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Obsidian Vault Template 3 | description: I use Obsidian to think, take notes, write essays, and publish this site. This is my bottom-up approach to note-taking and organizing things I am interested in. It embraces chaos and laziness to create emergent structure. 4 | tags: 5 | - Obsidian 6 | - Writing 7 | - Dev 8 | pubDate: 2024-04-16 9 | --- 10 | 11 | I use [Obsidian](https://stephango.com/obsidian) to think, take notes, write essays, and publish this site. This is my bottom-up approach to note-taking and organizing things I am interested in. It embraces chaos and laziness to create emergent structure. 12 | 13 | In Obsidian, a “vault” is simply a folder of files. This is important because it adheres to my [File over app](https://stephango.com/file-over-app) philosophy. If you want to create digital artifacts that last, they must be files you can control, in formats that are easy to retrieve and read. Obsidian gives you that freedom. 14 | 15 | The following is in no way dogmatic, just one example of how you can use Obsidian. Take the parts you like. 16 | 17 | ## Get started 18 | 1. [Download the vault](https://github.com/kepano/kepano-obsidian/archive/refs/heads/main.zip) or clone it from [the Github repo](https://github.com/kepano/kepano-obsidian) 19 | 2. Unzip the `.zip` file to a folder of your choosing 20 | 3. In Obsidian open the folder as a vault 21 | 22 | ## Theme and related tools 23 | 24 | - My theme [Minimal](https://stephango.com/minimal) 25 | - My [web clipper](https://stephango.com/obsidian-web-clipper) to save articles and pages from the web 26 | - [Obsidian Sync](https://obsidian.md/sync) to sync notes between my desktop, phone and tablet 27 | 28 | ## Plugins 29 | Some of my templates depend on plugins: 30 | 31 | - [Dataview](https://github.com/blacksmithgu/obsidian-dataview) for overview notes 32 | - [Leaflet](https://github.com/javalent/obsidian-leaflet) for maps 33 | 34 | ## Folders 35 | I use very few folders. I avoid folders because many of my entries belong to more than one area of thought. My system is oriented towards speed and laziness. I don’t want the overhead of having to consider where something should go. 36 | 37 | My personal notes are in the root of my vault. These are my journal entries, essays, [evergreen](https://stephango.com/evergreen-notes) notes, and personal ideas. If a note is in the root I know it’s something I came up with. I do not use the file explorer much for navigation, instead I navigate mostly using the quick switcher or clicking links. 38 | 39 | If you want to use this vault as a starting point the Categories and Templates folders contain everything you need. 40 | 41 | The folders I use: 42 | 43 | - **Attachments** for images, audio, videos, PDFs, etc. 44 | - **Clippings** for articles and web pages captured with my web clipper written by other people. 45 | - **Daily** for my daily notes, all named YYYY-MM-DD.md. 46 | - **References** for anything that refers to something that exists outside of my vault, e.g. books, movies, places, people, podcasts, etc. 47 | - **Templates** for templates. In my real personal vault the “Templates” folder is nested under “Meta” which also contains my personal style guide and other random notes about the vault. 48 | 49 | The folders I don’t use, but have created here for the sake of clarity. The notes in these folders would be in the root of my personal vault: 50 | - **Categories** contains top-level overviews of notes per category (e.g. books, movies, podcasts, etc). 51 | - **Notes** contains example notes. 52 | 53 | 54 | ## Structure, categories, and tags 55 | My notes are primarily organized using the category property. Categories are always links which makes it easy to get back to my top-level overviews. Some other rules I follow in my vault: 56 | 57 | - Avoid splitting content into multiple vaults. 58 | - Avoid folders for organization. 59 | - Avoid non-standard Markdown. 60 | - Always pluralize categories and tags. 61 | - Use `YYYY-MM-DD` dates everywhere. 62 | 63 | Having a [consistent style](https://stephango.com/style) collapses hundreds of future decisions into one, and gives me focus. I always pluralize tags so I never have to wonder what to name new tags. Choose the rules that feel comfortable to you. 64 | 65 | ## Templates and properties 66 | 67 | Almost every note I create starts from a [template](https://github.com/kepano/kepano-obsidian/tree/main/Templates). I use templates heavily because they allow me to lazily add information that will help me find the note later. I have a template for every category with properties at the top, to capture data such as: 68 | 69 | - Dates — created, start, end, published 70 | - People — author, director, artist, cast, host, guests 71 | - Themes — grouping by genre, type, topic, related notes 72 | - Locations — neighborhood, city, coordinates 73 | - Ratings — more on this below 74 | 75 | A few rules I follow for properties: 76 | - Property names and values should aim to be reusable across categories. This allows me to find things across categories, e.g. genre is shared across all media types, which means I can see an archive of Sci-fi books, movies and shows in one place. 77 | - Templates should aim to be composable, e.g. Person and Author are two different templates that can be added to the same note. 78 | - Short property names are faster to type, e.g. start instead of startdate. 79 | - Default to list type properties instead of text if there is any chance it might contain more than one link or value in the future. 80 | 81 | The [.obsidian/types.json](https://github.com/kepano/kepano-obsidian/blob/main/.obsidian/types.json) file lists which properties are assigned to which types (i.e. date, number, text, etc). 82 | 83 | ## Rating system 84 | Anything with a rating uses an integer from 1 to 7: 85 | 86 | - 7 — Perfect, must try, life-changing, go out of your way to seek this out 87 | - 6 — Excellent, worth repeating 88 | - 5 — Good, don’t go out of your way, but enjoyable 89 | - 4 — Passable, works in a pinch 90 | - 3 — Bad, don’t do this if you can 91 | - 2 — Atrocious, actively avoid, repulsive 92 | - 1 — Evil, life-changing in a bad way 93 | 94 | Why this scale? I like rating out of 7 better than 4 or 5 because I need more granularity at the top, for the good experiences, and 10 is too granular. 95 | 96 | ## Publishing to the web 97 | This site is written, edited, and published directly from Obsidian. To do this, I break one of my rules listed above — I have a separate vault for my site. I use a static site generator called [Jekyll](https://jekyllrb.com/) to automatically compile my notes into a website and convert them from Markdown to HTML. 98 | 99 | My publishing flow is easy to use, but a bit technical to set up. This is because I like to have full control over every aspect of my site’s layout. If you don’t need full control you might consider [Obsidian Publish](https://obsidian.md/publish) which is more user-friendly, and what I use for my [Minimal documentation site](https://minimal.guide/publish/download). 100 | 101 | For this site, I push notes from Obsidian to a GitHub repo using the [Obsidian Git](https://obsidian.md/plugins?id=obsidian-git) plugin. The notes are then automatically compiled using [Jekyll ](https://jekyllrb.com/)with my web host [Netlify](https://www.netlify.com/). I also use my [Permalink Opener](https://stephango.com/permalink-opener) plugin to quickly open notes in the browser so I can compare the draft and live versions. 102 | 103 | The color palette is [Flexoki](https://stephango.com/flexoki), which I created for this site. My Jekyll template is not public, but you can get similar results from [this template](https://github.com/maximevaillancourt/digital-garden-jekyll-template) by Maxime Vaillancourt. There are also many alternatives to Jekyll you can use to compile your site such as [Quartz](https://quartz.jzhao.xyz/), [Astro](https://astro.build/), [Eleventy](https://www.11ty.dev/), and [Hugo](https://gohugo.io/). 104 | 105 | :::info 106 | From [Obsidian Vault Template](https://stephango.com/vault) 107 | ::: -------------------------------------------------------------------------------- /src/content/post/sonner-getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Building a toast component 3 | description: Earlier this year, I built a Toast library for React called Sonner. In this article, I'll show you some of the lessons I've learned and mistakes I made while building it. 4 | tags: 5 | - Dev 6 | - Web 7 | - Animation 8 | pubDate: 2023-09-05 9 | --- 10 | Earlier this year, I built a Toast library for React called [Sonner](https://sonner.emilkowal.ski/). In this article, I'll show you some of the lessons I've learned and mistakes I made while building it. 11 | 12 | ## Animations 13 | 14 | Initially, I used CSS keyframes for enter and exit animations, but they aren't interruptible. A CSS transition can be interrupted and smoothly transition to a new value, even before the first transition has finished. You can see the difference below. 15 | 16 | To transition the toast when it enters the screen, essentially to mimic the enter animation, we used `useEffect` to set the mounted state to true after the first render. This way, the toast is rendered with transform: `translateY(100%)` and then transitions to `transform: translateY(0)`. The style is based on a data attribute. 17 | 18 | ```tsx 19 | React.useEffect(() => { setMounted(true);}, []); 20 | 21 | //... 22 | 23 |
  • 24 | ``` 25 | 26 | ## Stacking toasts 27 | 28 | To create the stacking effect, we multiply the gap between toasts by the index of the toast to get the y position. It's worth noting that every toast has `position: absolute` to make stacking easier. We also scale them down by 0.05 * index to create the illusion of depth. Here's the simplified CSS for it: 29 | 30 | ```tsx 31 | [data-sonner-toast][data-expanded="false"][data-front="false"] { 32 | --scale: var(--toasts-before) * 0.05 + 1; 33 | --y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale)) 34 | ); 35 | } 36 | ``` 37 | 38 | This works great until you have toasts with different heights, they won't stick out evenly. We fix it by simply making all the toasts the height of the toast in front when in stacked mode. Here's how the toasts would look with different heights: 39 | 40 | Add toast 41 | 42 | 43 | ## Swiping 44 | 45 | The toasts can be swiped down to dismiss. That's just a simple event listener on the toast which updates a variable responsible for the `translateY` value. 46 | 47 | ```tsx 48 | // This is a simplified version of the code 49 | const onMove = (event) => { 50 | const yPosition = event.clientY - pointerStartRef.current.y; 51 | 52 | toastRef.current?.style.setProperty("--swipe-amount", `${yPosition}px`); 53 | }; 54 | ``` 55 | 56 | The swipe is momentum-based, meaning you don't have to swipe until a specific threshold is met to remove the toast. If the swipe movement is fast enough, the toast will still be dismissed because the velocity is high enough. 57 | 58 | ```tsx 59 | const timeTaken = new Date().getTime() - dragStartTime.current.getTime(); 60 | const velocity = Math.abs(swipeAmount) / timeTaken; 61 | 62 | // Remove if the threshold is met or velocity is high enough 63 | if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { 64 | deleteToast(); 65 | } 66 | ``` 67 | 68 | 69 | 70 | ## Spatial consistency 71 | 72 | Initially, the toast was entering from the bottom, and you could swipe it to the right, as shown in [this tweet](https://twitter.com/emilkowalski_/status/1503372086038962178). However, that didn't feel natural since the toast didn't follow a symmetric path. If something enters from the bottom, it should also exit in the same direction. I learned this principle from the [Designing Fluid Interfaces](https://developer.apple.com/videos/play/wwdc2018/803/) talk from Apple. It's amazing and I highly recommend watching it. 73 | 74 | If you are interested in this type of stuff, then you might enjoy my course on animations, where I cover this and much more — [animations.dev](https://animations.dev/). 75 | 76 | 77 | 78 | ## Expanding toasts 79 | 80 | 81 | We calculate the expanded position of each toast by adding the heights of all preceding toasts and the gap between them. This value will become the new `translateY` when the user hovers over the toast area. 82 | 83 | ```tsx 84 | const toastsHeightBefore = React.useMemo(() => { 85 | return heights.reduce((prev, curr, reducerIndex) => { 86 | // Calculate offset up until current toast 87 | if (reducerIndex >= heightIndex) { 88 | return prev; 89 | } 90 | 91 | return prev + curr.height; 92 | }, 0); 93 | }, [heights, heightIndex]); 94 | 95 | // ... 96 | 97 | const offset = React.useMemo( 98 | () => heightIndex * GAP + toastsHeightBefore, 99 | [heightIndex, toastsHeightBefore] 100 | ); 101 | ``` 102 | 103 | ## State management 104 | 105 | To avoid using [context](https://react.dev/reference/react/createContext), we manage the state via the [Observer Pattern](https://javascriptpatterns.vercel.app/patterns/design-patterns/observer-pattern). We subscribe to the observable object in the `` component. Whenever the `toast()` function is called, the `` component (as the subscriber) is notified and updates its state. We can then render all the toasts using `Array.map()`. 106 | 107 | ```tsx 108 | function Toaster() { 109 | React.useEffect(() => { 110 | return ToastState.subscribe((toast) => { 111 | setToasts((toasts) => [...toasts, toast]); 112 | }); 113 | }, []); 114 | 115 | // ... 116 | 117 | return ( 118 |
      119 | {toasts.map((toast, index) => ( 120 | 121 | ))} 122 |
    123 | ); 124 | } 125 | ``` 126 | 127 | To create a new toast, we simply import `toast` and call it. There's no need for hooks or context, just a straightforward function call. 128 | 129 | ```tsx 130 | import { toast } from "sonner"; 131 | 132 | // ... 133 | 134 | toast("My toast"); 135 | ``` 136 | 137 | 138 | ## Hover state 139 | 140 | The hover state depends on whether we are hovering over one of the toasts. However, there are also gaps between the toasts. To address this, we add an `:after` pseudo-element to fill in these gaps, ensuring a consistent hover state. You will see these filled gaps depicted below. 141 | 142 | Add toast 143 | 144 | ## Pointer capture 145 | 146 | Once we start dragging, we set the toast to capture all future pointer events. This ensures that even if the mouse or our thumb moves outside the toast while dragging, the toast remains the target of the pointer events. As a result, dragging remains possible, even if we are outside of the toast, leading to a better user experience. 147 | 148 | ```tsx 149 | function onPointerDown(event) { 150 | event.target.setPointerCapture(event.pointerId); 151 | } 152 | ``` 153 | 154 | 155 | :::info 156 | From [Building a toast component](https://emilkowal.ski/ui/building-a-toast-component) 157 | ::: -------------------------------------------------------------------------------- /src/content/post/why-astro.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Why Astro? 3 | description: Astro is the web framework for building content-driven websites like blogs, marketing, and e-commerce. Astro is best-known for pioneering a new frontend architecture to reduce JavaScript overhead and complexity compared to other frameworks. If you need a website that loads fast and has great SEO, then Astro is for you. 4 | tags: 5 | - Dev 6 | - Astro 7 | - Web 8 | pubDate: 2024-12-03 9 | --- 10 | 11 | Astro is the web framework for building content-driven websites like blogs, marketing, and e-commerce. Astro is best-known for pioneering a new [frontend architecture](https://docs.astro.build/en/concepts/islands/) to reduce JavaScript overhead and complexity compared to other frameworks. If you need a website that loads fast and has great SEO, then Astro is for you. 12 | 13 | ## Features 14 | Astro is an all-in-one web framework. It includes everything you need to create a website, built-in. There are also hundreds of different [integrations](https://astro.build/integrations/) and [API hooks](https://docs.astro.build/en/reference/integrations-reference/) available to customize a project to your exact use case and needs. 15 | 16 | Some highlights include: 17 | 18 | - [Islands](https://docs.astro.build/en/concepts/islands/): A component-based web architecture optimized for content-driven websites. 19 | - [UI-agnostic](https://docs.astro.build/en/guides/framework-components/): Supports React, Preact, Svelte, Vue, Solid, HTMX, web components, and more. 20 | - [Server-first](https://docs.astro.build/en/guides/on-demand-rendering/): Moves expensive rendering off of your visitors’ devices. 21 | - [Zero JS, by default](https://docs.astro.build/en/basics/astro-components/): Less client-side JavaScript to slow your site down. 22 | - [Content collections](https://docs.astro.build/en/guides/content-collections/): Organize, validate, and provide TypeScript type-safety for your Markdown content. 23 | - [Customizable](https://docs.astro.build/en/guides/integrations-guide/): Tailwind, MDX, and hundreds of integrations to choose from. 24 | 25 | ## Design Principles 26 | 27 | Here are five core design principles to help explain why we built Astro, the problems that it exists to solve, and why Astro may be the best choice for your project or team. 28 | 29 | Astro is… 30 | 31 | 1. [Content-driven](https://docs.astro.build/en/concepts/why-astro/#content-driven): Astro was designed to showcase your content. 32 | 2. [Server-first](https://docs.astro.build/en/concepts/why-astro/#server-first): Websites run faster when they render HTML on the server. 33 | 3. [Fast by default](https://docs.astro.build/en/concepts/why-astro/#fast-by-default): It should be impossible to build a slow website in Astro. 34 | 4. [Easy to use](https://docs.astro.build/en/concepts/why-astro/#easy-to-use): You don’t need to be an expert to build something with Astro. 35 | 5. [Developer-focused](https://docs.astro.build/en/concepts/why-astro/#developer-focused): You should have the resources you need to be successful. 36 | 37 | ### Content-driven 38 | Astro was designed for building content-rich websites. This includes marketing sites, publishing sites, documentation sites, blogs, portfolios, landing pages, community sites, and e-commerce sites. If you have content to show, it needs to reach your reader quickly. 39 | 40 | By contrast, most modern web frameworks were designed for building web applications. These frameworks excel at building more complex, application-like experiences in the browser: logged-in admin dashboards, inboxes, social networks, todo lists, and even native-like applications like [Figma](https://figma.com/) and [Ping](https://ping.gg/). However with that complexity, they can struggle to provide great performance when delivering your content. 41 | 42 | Astro’s focus on content from its beginnings as a static site builder have allowed Astro to **sensibly scale up to performant, powerful, dynamic web applications** that still respect your content and your audience. Astro’s unique focus on content lets Astro make tradeoffs and deliver unmatched performance features that wouldn’t make sense for more application-focused web frameworks to implement. 43 | 44 | ### Server-first 45 | Astro leverages server rendering over client-side rendering in the browser as much as possible. This is the same approach that traditional server-side frameworks -- PHP, WordPress, Laravel, Ruby on Rails, etc. -- have been using for decades. But you don’t need to learn a second server-side language to unlock it. With Astro, everything is still just HTML, CSS, and JavaScript (or TypeScript, if you prefer). 46 | 47 | This approach stands in contrast to other modern JavaScript web frameworks like Next.js, SvelteKit, Nuxt, Remix, and others. These frameworks were built for client-side rendering of your entire website and include server-side rendering mainly to address performance concerns. This approach has been dubbed the **Single-Page App (SPA)**, in contrast with Astro’s **Multi-Page App (MPA)** approach. 48 | 49 | The SPA model has its benefits. However, these come at the expense of additional complexity and performance tradeoffs. These tradeoffs harm page performance -- critical metrics like [Time to Interactive (TTI)](https://web.dev/interactive/) -- which doesn’t make much sense for content-focused websites where first-load performance is essential. 50 | 51 | Astro’s server-first approach allows you to opt in to client-side rendering only if, and exactly as, necessary. You can choose to add UI framework components that run on the client. You can take advantage of Astro’s view transitions router for finer control over select page transitions and animations. Astro’s server-first rendering, either pre-rendered or on-demand, provides performant defaults that you can enhance and extend. 52 | 53 | ### Fast by default 54 | Good performance is always important, but it is especially critical for websites whose success depends on displaying your content. It has been well-proven that poor performance loses you engagement, conversions, and money. For example: 55 | 56 | - Every 100ms faster → 1% more conversions ([Mobify](https://web.dev/why-speed-matters/), earning +$380,000/yr) 57 | - 50% faster → 12% more sales ([AutoAnything](https://www.digitalcommerce360.com/2010/08/19/web-accelerator-revs-conversion-and-sales-autoanything/)) 58 | - 20% faster → 10% more conversions ([Furniture Village](https://www.thinkwithgoogle.com/intl/en-gb/marketing-strategies/app-and-mobile/furniture-village-and-greenlight-slash-page-load-times-boosting-user-experience/)) 59 | - 40% faster → 15% more sign-ups ([Pinterest](https://medium.com/pinterest-engineering/driving-user-growth-with-performance-improvements-cfc50dafadd7)) 60 | - 850ms faster → 7% more conversions ([COOK](https://web.dev/why-speed-matters/)) 61 | - Every 1 second slower → 10% fewer users ([BBC](https://www.creativebloq.com/features/how-the-bbc-builds-websites-that-scale)) 62 | 63 | In many web frameworks, it is easy to build a website that looks great during development only to load painfully slow once deployed. JavaScript is often the culprit, since many phones and lower-powered devices rarely match the speed of a developer’s laptop. 64 | 65 | Astro’s magic is in how it combines the two values explained above -- a content focus with a server-first architecture -- to make tradeoffs and deliver features that other frameworks cannot. The result is amazing web performance for every website, out of the box. Our goal: It should be nearly impossible to build a slow website with Astro. 66 | 67 | An Astro website can [load 40% faster with 90%](https://twitter.com/t3dotgg/status/1437195415439360003) less JavaScript than the same site built with the most popular React web framework. But don’t take our word for it: watch Astro’s performance leave Ryan Carniato (creator of Solid.js and Marko) [speechless](https://youtu.be/2ZEMb_H-LYE?t=8163). 68 | 69 | ### Easy to use 70 | **Astro’s goal is to be accessible to every web developer.** Astro was designed to feel familiar and approachable regardless of skill level or past experience with web development. 71 | 72 | The `.astro` UI language is a superset of HTML: any valid HTML is valid Astro templating syntax! So, if you can write HTML, you can write Astro components! But, it also combines some of our favorite features borrowed from other component languages like JSX expressions (React) and CSS scoping by default (Svelte and Vue). This closeness to HTML also makes it easier to use progressive enhancement and common accessibility patterns without any overhead. 73 | 74 | We then made sure that you could also use your favorite UI component languages that you already know, and even reuse components you might already have. React, Preact, Svelte, Vue, Solid, and others, including web components, are all supported for authoring UI components in an Astro project. 75 | 76 | Astro was designed to be less complex than other UI frameworks and languages. One big reason for this is that Astro was designed to render on the server, not in the browser. That means that you don’t need to worry about: hooks (React), stale closures (also React), refs (Vue), observables (Svelte), atoms, selectors, reactions, or derivations. There is no reactivity on the server, so all of that complexity melts away. 77 | 78 | One of our favorite sayings is: **opt in to complexity**. We designed Astro to remove as much “required complexity” as possible from the developer experience, especially as you onboard for the first time. You can build a “Hello World” example website in Astro with just HTML and CSS. Then, when you need to build something more powerful, you can incrementally reach for new features and APIs as you go. 79 | 80 | ### Developer-focused 81 | We strongly believe that Astro is only a successful project if people love using it. Astro has everything you need to support you as you build with Astro. 82 | 83 | Astro invests in developer tools like a great CLI experience from the moment you open your terminal, an official VS Code extension for syntax highlighting, TypeScript and Intellisense, and documentation actively maintained by hundreds of community contributors and available in 14 languages. 84 | 85 | Our welcoming, respectful, inclusive community on Discord is ready to provide support, motivation, and encouragement. Open a `#support` thread to get help with your project. Visit our dedicated `#showcase`channel for sharing your Astro sites, blog posts, videos, and even work-in-progress for safe feedback and constructive criticism. Participate in regular live events such as our weekly community call, “Talking and Doc’ing,” and API/bug bashes. 86 | 87 | As an open-source project, we welcome contributions of all types and sizes from community members of all experience levels. You are invited to join in roadmap discussions to shape the future of Astro, and we hope you’ll contribute fixes and features to the core codebase, compiler, docs, language tools, websites, and other projects. 88 | 89 | ::: info 90 | From [Why Astro?](https://docs.astro.build/en/concepts/why-astro/) 91 | ::: -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | declare var Heti: any; 6 | -------------------------------------------------------------------------------- /src/helpers/config-helper.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: Configuration handler 3 | */ 4 | import type { SlateConfig, ThemeOptions } from '@/typings/config'; 5 | 6 | /** Default configuration */ 7 | const defaultConfig: Partial = { 8 | lang: 'zh-CN', 9 | theme: { 10 | mode: 'auto', 11 | enableUserChange: true, 12 | }, 13 | readTime: false, 14 | lastModified: false, 15 | }; 16 | 17 | export function defineConfig(config: SlateConfig): SlateConfig { 18 | const mergedConfig: Partial = {}; 19 | 20 | if (typeof config.theme === 'string') { 21 | mergedConfig.theme = { 22 | ...(defaultConfig.theme as ThemeOptions), 23 | mode: config.theme, 24 | }; 25 | } else { 26 | mergedConfig.theme = { 27 | ...(defaultConfig.theme as ThemeOptions), 28 | ...config.theme, 29 | }; 30 | } 31 | 32 | return Object.assign({}, defaultConfig, config, mergedConfig); 33 | } 34 | -------------------------------------------------------------------------------- /src/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import slateConfig from '~@/slate.config'; 2 | import type { ThemeMode } from '@/typings/config'; 3 | 4 | /** 5 | * @description: Get full title 6 | * @param title 7 | */ 8 | export function getFullTitle(title: string) { 9 | return `${title}${!!title && slateConfig.title ? ' | ' : ''}${slateConfig.title}`; 10 | } 11 | 12 | /** 13 | * @description: Set theme mode 14 | * @param mode 15 | */ 16 | export function setThemeMode(mode: ThemeMode) { 17 | document.documentElement.className = mode; 18 | document.documentElement.dataset.theme = mode; 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import i18next from 'i18next' 2 | import zhCn from './lang/zh-cn'; 3 | import enUS from './lang/en-us' 4 | import slateConfig from '~@/slate.config'; 5 | 6 | await i18next.init({ 7 | lng: slateConfig.lang, 8 | fallbackLng: 'es-US', 9 | resources: { 10 | 'zh-CN': { 11 | translation: zhCn 12 | }, 13 | 'en-US': { 14 | translation: enUS 15 | } 16 | } 17 | }) 18 | 19 | export default i18next; -------------------------------------------------------------------------------- /src/i18n/lang/en-us.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | common: { 3 | /** all tags */ 4 | all: 'All', 5 | }, 6 | /** blog page */ 7 | blog: { 8 | /** last modified */ 9 | lastModified: 'Last edited', 10 | /** reading time */ 11 | readingTime: '{{minutes}} Min Read' 12 | }, 13 | /** 404 page */ 14 | 404: { 15 | /** page text */ 16 | pageText: 'Page Not Found', 17 | /** back button text */ 18 | backBtnText: 'Back to Home' 19 | } 20 | } -------------------------------------------------------------------------------- /src/i18n/lang/zh-cn.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | common: { 3 | /** all tags */ 4 | all: '全部', 5 | }, 6 | blog: { 7 | lastModified: '编辑于', 8 | readingTime: '{{minutes}} 分钟阅读' 9 | }, 10 | 404: { 11 | pageText: '你访问的页面不存在', 12 | backBtnText: '返回首页' 13 | } 14 | } -------------------------------------------------------------------------------- /src/pages/404.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import i18next from '@/i18n'; 3 | import PageLayout from '@/components/layouts/PageLayout.astro'; 4 | import Button from '@/components/button'; 5 | --- 6 | 7 | 8 |
    9 |

    404

    10 |

    {i18next.t('404.pageText')}

    11 | 12 |
    13 |
    14 | 15 | -------------------------------------------------------------------------------- /src/pages/blog/[...slug].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import dayjs from 'dayjs'; 3 | import { type CollectionEntry, getCollection } from 'astro:content'; 4 | import i18next from '@/i18n'; 5 | import PageLayout from '@/components/layouts/PageLayout.astro'; 6 | import ArticleJsonLd from '@/components/json-ld/article.astro'; 7 | import Button from '@/components/button'; 8 | import AffixTitle from '@/components/affix-title'; 9 | import Toc from '@/components/toc'; 10 | import CodeGroupEvent from '@/components/code-group-event'; 11 | import slateConfig from '~@/slate.config'; 12 | import { getFullTitle } from '@/helpers/utils'; 13 | import 'remark-block-containers/css'; 14 | import '@/assets/style/blog.css'; 15 | 16 | export async function getStaticPaths() { 17 | const postEntries = await getCollection('post', ({ data }) => { 18 | return import.meta.env.DEV || data.draft !== true; 19 | }); 20 | 21 | return postEntries.map((post) => ({ 22 | params: { slug: post.slug }, 23 | props: post, 24 | })); 25 | } 26 | 27 | type Props = CollectionEntry<'post'>; 28 | 29 | const post = Astro.props; 30 | const { Content, remarkPluginFrontmatter, headings } = await post.render(); 31 | const lastModifiedDate = dayjs( 32 | remarkPluginFrontmatter.lastModified 33 | ? remarkPluginFrontmatter.lastModified 34 | : undefined, 35 | ); 36 | const lastModified = lastModifiedDate.isSame(dayjs(), 'year') 37 | ? lastModifiedDate.format('MMM DD') 38 | : lastModifiedDate.format('MMM DD, YYYY'); 39 | const pubDate = dayjs(post.data.pubDate); 40 | const formattedPubDate = pubDate.isSame(dayjs(), 'year') 41 | ? pubDate.format('MMM DD') 42 | : pubDate.format('MMM DD, YYYY'); 43 | --- 44 | 45 | 46 | 53 | 54 | 55 | 56 |
    57 | 58 |
    59 | 60 |
    61 |
    62 |

    {post.data.title}

    63 |
    64 | {formattedPubDate} 65 | {!!slateConfig.readTime && ·} 66 | { 67 | slateConfig.readTime && ( 68 |
    69 | {i18next.t('blog.readingTime', { 70 | minutes: remarkPluginFrontmatter.minutesRead, 71 | })} 72 |
    73 | ) 74 | } 75 |
    76 |
    77 | 78 | { 79 | slateConfig.lastModified && ( 80 |
    81 | {i18next.t('blog.lastModified')} {lastModified} 82 |
    83 | ) 84 | } 85 |
    86 | 87 | 88 | 94 | 95 |
    96 | 97 | 98 | 107 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from 'astro:content'; 3 | import i18next from '@/i18n'; 4 | import Search from '@/components/search'; 5 | import PageLayout from '@/components/layouts/PageLayout.astro'; 6 | // import Button from '@/components/button'; 7 | import slateConfig from '~@/slate.config'; 8 | 9 | const postCollection = await getCollection('post', ({ data }) => { 10 | return import.meta.env.DEV || data.draft !== true; 11 | }); 12 | 13 | const tagCounts = postCollection.reduce>( 14 | (res, post) => { 15 | const postTags = post.data.tags; 16 | if (!postTags || !postTags.length) return res; 17 | postTags.forEach((tag) => { 18 | if (tag.trim() === '') return; 19 | 20 | if (res[tag]) { 21 | res[tag]++; 22 | } else { 23 | res[tag] = 1; 24 | } 25 | }); 26 | return res; 27 | }, 28 | { 29 | [i18next.t('common.all')]: postCollection.length, 30 | }, 31 | ); 32 | 33 | const tags = Object.keys(tagCounts).map((tag) => ({ 34 | name: tag, 35 | count: tagCounts[tag], 36 | })); 37 | 38 | const posts = [...postCollection] 39 | .sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime()) 40 | .map((post) => ({ 41 | id: post.id, 42 | slug: post.slug, 43 | url: `/blog/${post.slug}`, 44 | data: post.data, 45 | })); 46 | --- 47 | 48 | 49 |
    50 |

    51 | {slateConfig.title} 52 |

    53 |

    {slateConfig.description}

    54 | 58 |
    59 | 60 |
    61 | 78 | 79 |
    80 | 81 | 82 |
    83 |
      84 | { 85 | tags.map(({ name, count }) => ( 86 |
    • 87 | {name} 88 | {count} 89 |
    • 90 | )) 91 | } 92 |
    93 |
    94 | 95 |
    96 | -------------------------------------------------------------------------------- /src/pages/robots.txt.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from 'astro'; 2 | 3 | const getRobotsTxt = (sitemapURL: URL) => ` 4 | User-agent: * 5 | Allow: / 6 | Sitemap: ${sitemapURL.href} 7 | `; 8 | 9 | export const GET: APIRoute = ({ site }) => { 10 | const sitemapURL = new URL('sitemap-index.xml', site); 11 | return new Response(getRobotsTxt(sitemapURL)); 12 | }; 13 | -------------------------------------------------------------------------------- /src/pages/rss.xml.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: kim 3 | * @Description: rss 提要 4 | */ 5 | import rss from '@astrojs/rss'; 6 | import { experimental_AstroContainer } from "astro/container"; 7 | import { loadRenderers } from "astro:container"; 8 | import { getCollection, render } from 'astro:content'; 9 | import { getContainerRenderer as mdxContainerRenderer } from "@astrojs/mdx"; 10 | import sanitizeHtml from 'sanitize-html'; 11 | import slateConfig from '~@/slate.config'; 12 | 13 | export async function GET(context) { 14 | const blog = await getCollection('post'); 15 | const renderers = await loadRenderers([mdxContainerRenderer()]); 16 | const container = await experimental_AstroContainer.create({ 17 | renderers, 18 | }); 19 | 20 | const postItems = await Promise.all(blog 21 | .filter((post) => !post.data.draft) 22 | .sort((a, b) => b.data.pubDate - a.data.pubDate) 23 | .map(async (post) => { 24 | const { Content } = await render(post); 25 | const htmlStr = await container.renderToString(Content); 26 | 27 | return { 28 | link: `/blog/${post.slug}/`, 29 | title: post.data.title, 30 | content: sanitizeHtml(htmlStr, { 31 | allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']) 32 | }), 33 | ...post.data, 34 | } 35 | })); 36 | 37 | const rssOptions = { 38 | stylesheet: '/pretty-feed-v3.xsl', 39 | title: slateConfig.title, 40 | description: slateConfig.description, 41 | site: context.site, 42 | trailingSlash: false, 43 | items: postItems, 44 | } 45 | 46 | if(slateConfig.follow) { 47 | rssOptions.customData = ` 48 | ${slateConfig.follow.feedId} 49 | ${slateConfig.follow.userId} 50 | `; 51 | } 52 | 53 | return rss(rssOptions); 54 | } 55 | -------------------------------------------------------------------------------- /src/typings/config.ts: -------------------------------------------------------------------------------- 1 | import type { SitemapOptions } from '@astrojs/sitemap'; 2 | 3 | export const languages = ['zh-CN', 'en-US'] as const; 4 | export type LangType = (typeof languages)[number]; 5 | 6 | export const theme = ['auto', 'light', 'dark'] as const; 7 | /** Theme mode */ 8 | export type ThemeMode = (typeof theme)[number]; 9 | export interface ThemeOptions { 10 | /** Mode */ 11 | mode: ThemeMode; 12 | /** Whether to allow user to change theme */ 13 | enableUserChange?: boolean; 14 | } 15 | 16 | /** 社交链接配置 */ 17 | export interface SocialLink { 18 | icon: SocialLinkIcon; 19 | link: string; 20 | ariaLabel?: string; 21 | } 22 | 23 | type SocialLinkIcon = 24 | | 'dribbble' 25 | | 'facebook' 26 | | 'figma' 27 | | 'github' 28 | | 'instagram' 29 | | 'link' 30 | | 'mail' 31 | | 'notion' 32 | | 'rss' 33 | | 'threads' 34 | | 'x' 35 | | 'youtube' 36 | | { svg: string }; 37 | 38 | export interface SlateConfig { 39 | /** Final deployment link */ 40 | site: string; 41 | /** Language */ 42 | lang?: LangType; 43 | /** Theme */ 44 | theme?: ThemeOptions; 45 | /** Avatar */ 46 | avatar?: string; 47 | /** Sitemap configuration */ 48 | sitemap?: SitemapOptions; 49 | /** Website title */ 50 | title: string; 51 | /** Website description */ 52 | description: string; 53 | /** Whether to show reading time */ 54 | readTime?: boolean; 55 | /** Whether to show last modified time */ 56 | lastModified?: boolean; 57 | /** Docsearch configuration */ 58 | algolia?: { 59 | appId: string; 60 | apiKey: string; 61 | indexName: string; 62 | }; 63 | /** Website footer configuration */ 64 | footer?: { 65 | copyright: string; 66 | }; 67 | /** Follow subscription authentication configuration */ 68 | follow?: { 69 | feedId: string; 70 | userId: string; 71 | }; 72 | /** 社交链接 */ 73 | socialLinks?: SocialLink[]; 74 | } 75 | -------------------------------------------------------------------------------- /src/typings/global.ts: -------------------------------------------------------------------------------- 1 | /** Theme value */ 2 | export enum ThemeValue { 3 | /** Auto */ 4 | Auto = 'auto', 5 | /** Light */ 6 | Light = 'light', 7 | /** Dark */ 8 | Dark = 'dark', 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [ 4 | ".astro/types.d.ts", 5 | "src", 6 | "slate.config.ts" 7 | ], 8 | "exclude": [ 9 | "node_modules", 10 | "dist" 11 | ], 12 | "compilerOptions": { 13 | "strictNullChecks": true, 14 | "baseUrl": ".", 15 | "jsx": "react-jsx", 16 | "paths": { 17 | "@/*": ["src/*"], 18 | "~@/*": ["./*"] 19 | }, 20 | "jsxImportSource": "react", 21 | } 22 | } 23 | --------------------------------------------------------------------------------