大 更 新

This commit is contained in:
RiseForever
2026-08-12 01:11:32 +08:00
parent 6570961102
commit c382cd642a
69 changed files with 19471 additions and 16 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.astro/
.astro-cache/
.env
.env.*
!.env.example
pnpm-debug.log*
+97 -14
View File
@@ -1,18 +1,101 @@
MIT License
## Attribution-NonCommercial-NoDerivatives 4.0 International
Copyright (c) 2026 riseforever2026
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
### Section 1 Definitions.
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
1. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
2. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section [2(b)(1)-(2)](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s2b) are not Copyright and Similar Rights.
3. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
4. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
5. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
6. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
7. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
8. NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
9. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
10. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
11. You means the individual or entity exercising the Licensed Rights under this Public License. **Your** has a corresponding meaning.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
### Section 2 Scope.
1. **License grant**.
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
1. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
2. produce and reproduce, but not Share, Adapted Material for NonCommercial purposes only.
2. **Exceptions and Limitations**. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. **Term**. The term of this Public License is specified in Section [6(a)](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s6a).
4. **Media and formats; technical modifications allowed**. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section [2(a)(4)](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s2a4) never produces Adapted Material.
5. Downstream recipients.
1. Offer from the Licensor Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
2. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section [3(a)(1)(A)(i)](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s3a1Ai).
2. **Other rights**.
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes.
### Section 3 License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
1. **Attribution**.
1. If You Share the Licensed Material, You must:
1. retain the following if it is supplied by the Licensor with the Licensed Material:
1. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
2. a copyright notice;
3. a notice that refers to this Public License;
4. a notice that refers to the disclaimer of warranties;
5. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
2. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
3. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
For the avoidance of doubt, You do not have permission under this Public License to Share Adapted Material.
2. You may satisfy the conditions in Section [3(a)(1)](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s3a1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section [3(a)(1)(A)](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s3a1A) to the extent reasonably practicable.
### Section 4 Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
1. for the avoidance of doubt, Section [2(a)(1)](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s2a1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only and provided You do not Share Adapted Material;
2. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
3. You must comply with the conditions in Section [3(a)](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s3a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section [4](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s4) supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
### Section 5 Disclaimer of Warranties and Limitation of Liability.
1. **Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.**
2. **To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.**
3. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
### Section 6 Term and Termination.
1. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
2. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section [6(b)](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s6b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
3. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
4. Sections [1](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s1), [5](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s5), [6](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s6), [7](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s7), and [8](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode#s8) survive termination of this Public License.
### Section 7 Other Terms and Conditions.
1. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
2. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
### Section 8 Interpretation.
1. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
2. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
3. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
4. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
+225
View File
@@ -0,0 +1,225 @@
# Mirages Astro
Mirages 是一个基于 Astro 的静态博客主题,用于发布文章和独立页面。旧版 PHP 文件位于本项目之外,保持不变。
本文档说明 Astro 主题的常用配置、Twikoo 评论和文章 Frontmatter。
## 运行项目
运行项目需要 Node.js 和 Corepack。
```sh
corepack pnpm install
corepack pnpm dev
```
`corepack pnpm check` 用于执行 Astro 类型和内容检查;`corepack pnpm build` 用于构建静态站点并生成 Pagefind 搜索索引;`corepack pnpm preview` 用于预览构建后的站点。
## 基本配置
主要配置文件是 `src/site.config.ts`。发布前至少修改以下内容:
| 配置 | 位置 | 用途 |
| --- | --- | --- |
| 站点名称 | `site.title` | 页面标题、导航站点名 |
| 站点描述 | `site.description` | 默认 description、RSS 描述 |
| 作者信息 | `site.author` | 头像、作者名、简介 |
| 正式域名 | `site.url` | canonical、RSS、sitemap、robots.txt 和社交分享地址 |
| 导航菜单 | `navigation` | 顶部和移动端导航 |
| Banner | `banner` | 首页横幅标题、图片、尺寸和遮罩 |
| 默认封面 | `cards.defaultCovers` | 文章没有封面时使用的候选图片 |
| 标签云数量 | `archives.tagLimit` | 归档页显示的最多标签数,设为 `0` 显示全部 |
| 页脚 | `footer` | 版权、说明和页脚链接 |
`site.url` 必须替换为真实的 HTTPS 域名,不要保留 `https://example.com`,否则 sitemap、canonical 和 Open Graph URL 都会指向错误地址。
### 网站图标 Favicon
当前 `src/layouts/BaseLayout.astro` 中的默认配置使用空白 data URI 图标。添加自己的图标时:
1. 将图标放入 `public/`,例如 `public/favicon.svg``public/favicon.png`
2. 编辑 `src/layouts/BaseLayout.astro``<head>`,把默认图标:
```astro
<link rel="icon" href="data:," />
```
替换为:
```astro
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
```
PNG 图标示例:
```astro
<link rel="icon" href="/favicon.png" type="image/png" sizes="32x32" />
```
如需 Apple 设备图标,可同时添加:
```astro
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
```
`public/` 下的文件会原样复制到站点根目录。修改后运行 `pnpm build`,并使用强制刷新检查浏览器缓存。
### 其他基本信息
颜色主题、默认主题和主题色位于 `appearance`;RSS、搜索、导航和自定义工具按钮也都在 `src/site.config.ts` 中配置。自定义工具按钮可按 `toolbarItems` 的类型定义添加 `link``rss` 项。`astro.config.mjs` 会读取 `site.url` 用于 sitemap 和站点构建配置。
## Twikoo 配置
Twikoo 评论默认关闭。完整配置入口是:
```text
astro/src/site.config.ts
```
将:
```ts
comments: { provider: 'none' },
```
改为:
```ts
comments: {
provider: 'twikoo',
envId: '你的 Twikoo 云函数地址',
region: 'cn',
lang: 'zh-CN'
},
```
其中 `envId` 就是 Twikoo 云函数或后端服务地址。例如使用 Vercel、Netlify、CloudBase 或自有服务器部署后,填写部署平台提供的公开访问地址:
```ts
envId: 'https://your-twikoo-api.example.com'
```
不要填写管理后台地址,也不要填写带具体文章路径的 URL。这里填写的是 Twikoo 服务根地址,前端会将它传给 `twikoo.init()`
只有使用腾讯云 CloudBase 环境 ID 时才需要填写 `region`;填写 URL 型云函数或自托管服务地址时不要填写它。`lang` 可按需要修改,默认使用站点语言。
然后在需要评论的文章 Frontmatter 中启用:
```yaml
comments: true
```
Twikoo 客户端脚本由 `src/components/TwikooComments.astro` 从 jsDelivr 延迟加载,只有评论区域接近视口时才初始化。评论数量由 `src/components/TwikooCommentCounts.astro` 读取。修改配置后重新构建并部署:
```sh
pnpm build
```
如果页面显示“评论暂时不可用”,依次检查云函数地址是否能从浏览器直接访问、CORS 配置、Twikoo 环境变量和 `envId` 是否包含多余路径或空格。
## 文章 Frontmatter
文章位于 `src/content/posts`,独立页面位于 `src/content/pages`。两者使用相同的字段结构:
```yaml
title: 文章标题 # 必填
description: 简短摘要 # 可选,文章摘要和 SEO 描述
pubDate: 2026-07-30
updatedDate: 2026-08-01
draft: false
slug: custom-url
cover: https://images.example.com/article-cover.jpg
hero: https://images.example.com/article-hero.jpg
categories: [Notes]
tags: [astro]
textTone: auto
toc:
enabled: true
position: right
comments: true
math: false
mermaid: false
```
### 字段说明
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `title` | 字符串 | 必填 | 文章标题。用于文章页标题、卡片和 SEO 标题。 |
| `description` | 字符串 | 无 | 文章摘要;用于 SEO description,未填写时会从正文生成摘要。 |
| `pubDate` | 日期 | 必填 | 发布日期,支持 `YYYY-MM-DD` 等可被 JavaScript 解析的日期格式。 |
| `updatedDate` | 日期 | 无 | 修改日期,适合在文章有实质更新时填写。 |
| `draft` | 布尔值 | `false` | `true` 时不生成文章路由,也不会出现在列表、RSS 和 sitemap 中。 |
| `slug` | 字符串 | 文件名/路径 | 自定义文章 URL slug。建议只使用稳定的英文、数字和短横线。 |
| `cover` | 字符串 | 无 | 文章卡片和文章 Banner 的封面 URL。支持绝对 URL 和项目内路径。 |
| `hero` | 字符串 | 无 | 兼容旧内容的 Banner 图片字段;没有 `cover` 时可作为图片来源。 |
| `categories` | 字符串数组 | `[]` | 文章分类,例如 `[随笔, 写作]`。 |
| `tags` | 字符串数组 | `[]` | 文章标签,例如 `[Astro, 前端]`。 |
| `textTone` | `light` / `dark` / `auto` | 无 | Banner 文字颜色。`auto` 会在图片加载后根据图片亮度判断。 |
| `toc` | 布尔值或对象 | `false` | 是否显示目录。对象可设置 `enabled``position: left/right`。 |
| `comments` | 布尔值 | `false` | 是否显示 Twikoo 评论区;还需要先在站点配置中启用 Twikoo。 |
| `math` | 布尔值 | `false` | 是否加载 KaTeX,用于 LaTeX 数学公式。 |
| `mermaid` | 布尔值 | `false` | 是否加载 Mermaid,用于 `mermaid` 代码块。 |
Frontmatter 必须放在 Markdown 文件开头的两个 `---` 之间。数组可以使用行内格式,也可以使用多行格式:
```yaml
categories:
- 随笔
- 写作
tags:
- Astro
- 网站
```
目录配置示例:
```yaml
toc:
enabled: true
position: left
```
文章卡片在构建时按以下顺序选择图片:`cover`、兼容旧内容的 `hero`、Markdown/MDX 正文中的第一张标准 Markdown 图片或 HTML `img`,最后使用 `siteConfig.cards.defaultCovers` 中的确定性候选图片。正文中的相对图片地址会根据最终文章 URL 解析,例如 `/posts/hello/` 中的 `images/photo.jpg` 会解析为 `/posts/hello/images/photo.jpg`。如果 `defaultCovers` 为空且没有其他图片,卡片会使用确定性的纯色背景作为兜底。
如果文章没有 `cover``hero`,只在正文中添加图片也可以:
```md
![Article image](./images/article.jpg)
```
`src/content/posts/first-light.md``content-workflow.md``quiet-default.md` 分别演示了 Frontmatter 封面、正文图片和默认封面路径。
`draft: true` 会将文章从文章列表、RSS、sitemap 和生成路由中排除。`toc` 也可以直接设置为 `true``false`;对象形式支持 `position: left``right`。目录标题由 Astro `render()` 返回的 headings 生成,并使用真实的 `#slug` 锚点。桌面端显示侧边目录,较小屏幕使用原生 `details` 控件,不会导致页面整体偏移。
包含 LaTeX 公式时设置 `math: true`。Markdown 通过 `remark-math``rehype-katex` 构建,只有这些页面会加载 KaTeX CSS。这是构建时渲染,不会加载 MathJax。
包含 `mermaid` 代码块时设置 `mermaid: true`。Mermaid 只会在这些页面中动态加载,渲染主题会跟随当前深色模式设置。
## 路由和工具
- `/` 是文章首页;`/posts/.../` 是公开文章页面。
- `/about/` 和其他非保留路径由 `src/content/pages` 生成。
- `/archives/``/categories/.../``/tags/.../` 提供归档及分类、标签页面。
- `/rss.xml` 是公开文章 RSS Feed。
- `/sitemap-index.xml``@astrojs/sitemap` 生成;`/robots.txt` 会将搜索引擎指向 sitemap。
- `build` 会为 `dist` 生成 Pagefind 索引;也可以使用 `search:index` 单独重新生成索引。
- Twikoo 通过 `siteConfig.comments` 按需启用。浏览器端 CDN 地址固定使用已安装的 `twikoo` `1.7.15` 版本,并以延迟方式加载评论和评论数量。
## Markdown 短代码
短代码由 `src/lib/shortcodes.ts` 在构建时处理;现有 `.md` 文件继续支持,不需要迁移到 MDX。块级语法是 `[name key="value"]...[/name]`GitHub 卡片还支持 `[github repo="owner/repo" /]``[!] text``[!/ ] text` 是行内提示。属性支持 ASCII 引号、中文排版引号、不加引号的值和布尔标记。代码围栏不会被短代码处理。
| 语法 | 支持情况 | 说明 |
| --- | --- | --- |
| `[!]``[!/ ]` | 支持 | 行内提示,仅识别这些明确形式 |
| `button``btn` | 支持 | 支持 `href``url``link`;仅允许 http/https/mailto/tel 和相对地址 |
| `file` | 支持 | URL 规则与按钮相同 |
| `tag``label` | 支持 | `type` 支持 primary、success、warning、danger、info、default;支持 `outline` |
| `hint``tip` | 支持 | `type` 支持 warn、warning、error、danger、success、info;可选 `title` |
| `collapse` | 支持 | 使用原生且可访问的 `details`/`summary`;支持 Markdown 和嵌套折叠 |
| `tabs``tab` | 支持 | 静态可访问标签页,通过少量原生客户端脚本切换;多个实例互不影响 |
| `github` | 支持 | 构建时生成 GitHub 仓库卡片,并验证 `owner/repo`。可选 `description``stars``forks``lastCommit``commitDate``readMore``download` |
| 未知或不安全的值 | 安全兜底 | 无效链接和仓库会作为普通文本输出 |
`selected` 用于指定初始标签页。执行 `build` 时,`[github repo="owner/repo" /]` 使用的仓库会从 GitHub 公开仓库 API 获取,并缓存到 `.astro-cache/github.json`,缓存时间为 24 小时。可以设置 `GITHUB_CACHE_TTL_MS``GITHUB_API_TIMEOUT_MS` 调整默认值,也可以设置 `GITHUB_TOKEN` 以提高 API 访问限制;Token 不会写入缓存或构建输出。项目只使用公开仓库元数据。API 出错或处于开发模式时,会安全降级为仓库链接和 `—`,不会阻断构建。短代码中显式填写的属性始终优先于 API 数据;`readMore` 默认使用 `html_url``download` 使用 API 返回的 `default_branch`。浏览器端不会请求 GitHub API。已移除的短代码会触发构建错误,而不会作为普通 Markdown 输出。
+36 -2
View File
@@ -1,3 +1,37 @@
# Eidolon
![Astro](https://img.shields.io/badge/Built%20with-Astro-dee2e6?logo=astro&logoColor=white&style=for-the-badge) ![Eidolon](https://img.shields.io/badge/theme-eidolon-yellow?style=for-the-badge)
A aesthetic theme that built with Astro.
---
这里是 **⌈路明笔记⌋** 的代码仓库。
[GitHub](https://github.com/virelyx258/Blog) | [云仓](https://src.luming.cool/riseforever2026/Blog)
之所以在自己的服务器上托管一份,是因为 GitHub 直连很不稳定,导致我无法愉快地更新博客。自托管后,无需挂代理即可轻松更新。
两个仓库互为镜像关系,同步更新。
## 主题
Eidolon,基于 [Astro](https://astro.build) 进行构建的主题,设计思想参照了 Typecho 主题 [Mirages](https://get233.com/archives/mirages-intro.html)。
我曾是 Mirages 主题用户,后为追求极致速度,便产生了移植主题到 Astro 的想法。
一款外观相似、免费的主题,如果开源,可能会对原付费主题的销售造成影响。但细想想,我们其实不在一个赛道。喜欢使用 Typecho 博客系统、追求简约的用户,一定还会选择 Mirages 的。本主题的开源,只是为 Astro 用户提供另一种选择。
特点:
- 美观、简洁、耐看;
- 页面自适应,根据屏幕大小判断显示顶栏或侧栏;
- 完美支持 Pjax 无刷新加载;
- 支持用户前端深浅色模式切换、字体切换、字体大小调整等功能;
- 支持 LaTeX 数学公式解析;
- 支持多样的短代码;
- 原生支持友链卡片。
欢迎使用。如果能帮助到你,欢迎去 [GitHub 仓库](https://github.com/virelyx258/astro_theme_) 给我点个 Star。
## 版权协议
本站的文章基于 [CC-BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/deed.zh-hans) 协议公开。
转载请注明原文链接。[协议](https://creativecommons.org/licenses/by-nc-nd/4.0/deed.zh-hans)不允许对本站文章的再混合、转换、或者二次创作。若认为有必要二创,请邮件沟通。
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig } from 'astro/config';
import { unified } from '@astrojs/markdown-remark';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import shortcodes from './src/lib/shortcodes.ts';
import externalLinks from './src/lib/external-links.ts';
import { siteConfig } from './src/site.config.ts';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
let githubData = {};
try {
const githubCachePath = resolve(process.cwd(), process.env.GITHUB_CACHE_PATH ?? '.astro-cache/github.json');
githubData = JSON.parse(readFileSync(githubCachePath, 'utf8')).repositories ?? {};
} catch {
// The build script creates this cache before Astro loads the config.
}
export default defineConfig({
site: siteConfig.site.url,
integrations: [sitemap()],
vite: {
plugins: [tailwindcss()]
},
markdown: {
shikiConfig: {
themes: {
light: 'github-light',
dark: 'github-dark'
},
defaultColor: false
},
processor: unified({
remarkPlugins: [remarkMath, [shortcodes, { githubData, siteUrl: siteConfig.site.url }]],
rehypePlugins: [rehypeKatex, [externalLinks, { siteUrl: siteConfig.site.url }]]
})
}
});
+8763
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "eidolon",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"new-post": "node scripts/new-post.mjs",
"check": "astro check",
"build": "node scripts/clean-astro-cache.mjs && node scripts/fetch-github.mjs && astro build && pagefind --site dist",
"search:index": "pagefind --site dist",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/markdown-remark": "^7.2.2",
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.3",
"@lucide/astro": "^1.27.0",
"astro": "^7.2.0",
"hast-util-to-html": "^9.0.5",
"katex": "^0.18.1",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-hast": "^13.2.0",
"mermaid": "^11.16.0",
"micromark-extension-gfm": "^3.0.0",
"pagefind": "^1.3.0",
"pangu": "^9.1.0",
"photoswipe": "^5.4.4",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"twikoo": "1.7.15",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@playwright/test": "^1.62.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.1.2",
"tailwindcss": "^4.3.3",
"typescript": "^5.8.3"
},
"packageManager": "pnpm@10.12.4"
}
+4
View File
@@ -0,0 +1,4 @@
exclude_selectors:
- '[data-pagefind-ignore]'
- '.katex'
- '.search-panel'
+5469
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
 astro  v5.18.2 ready in 11 ms
┃ Local http://127.0.0.1:4321/
+7
View File
@@ -0,0 +1,7 @@
import { rm } from 'node:fs/promises';
await Promise.all([
rm('dist', { recursive: true, force: true }),
rm('.astro', { recursive: true, force: true }),
rm('node_modules/.astro', { recursive: true, force: true })
]);
+100
View File
@@ -0,0 +1,100 @@
import { readdir, readFile, writeFile, mkdir, rm } from 'node:fs/promises';
import { dirname, join } from 'node:path';
const root = process.cwd();
const cacheDir = join(root, '.astro-cache');
const cachePath = process.env.GITHUB_CACHE_PATH ? join(root, process.env.GITHUB_CACHE_PATH) : join(cacheDir, 'github.json');
const contentDir = join(root, 'src', 'content');
const ttlMs = Number(process.env.GITHUB_CACHE_TTL_MS ?? 24 * 60 * 60 * 1000);
const timeoutMs = Number(process.env.GITHUB_API_TIMEOUT_MS ?? 8000);
const validRepository = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,38})\/[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})$/;
async function contentFiles(directory) {
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) files.push(...await contentFiles(path));
else if (/\.(md|mdx)$/i.test(entry.name)) files.push(path);
}
return files;
}
function repositoriesFrom(source) {
const repositories = [];
let fence = null;
for (const line of source.matchAll(/^[^\n]*(?:\n|$)/gm)) {
const text = line[0].trim();
const fenceMatch = text.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
continue;
}
if (fence) continue;
const tag = text.match(/^\[github\b([^\]]*)/i);
const value = tag?.[1].match(/(?:^|\s)(?:repo|repository)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/i);
const repository = value?.[1] ?? value?.[2] ?? value?.[3];
if (repository && validRepository.test(repository.trim())) repositories.push(repository.trim().toLowerCase());
}
return repositories;
}
async function readCache() {
try {
const cache = JSON.parse(await readFile(cachePath, 'utf8'));
return cache?.repositories && typeof cache.repositories === 'object' ? cache : { repositories: {} };
} catch (error) {
if (error?.code !== 'ENOENT') console.warn('[github] Could not read .astro-cache/github.json; rebuilding it.');
return { repositories: {} };
}
}
async function fetchRepository(repository) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'mirages-astro-build' };
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
try {
const response = await fetch(`https://api.github.com/repos/${repository}`, { headers, signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.private) throw new Error('private repositories are not supported');
if (typeof data.stargazers_count !== 'number' || typeof data.forks_count !== 'number' || typeof data.html_url !== 'string') throw new Error('unexpected API response');
return {
stars: String(data.stargazers_count),
forks: String(data.forks_count),
description: typeof data.description === 'string' ? data.description : '',
commitDate: typeof data.pushed_at === 'string' ? data.pushed_at : '',
htmlUrl: data.html_url,
defaultBranch: typeof data.default_branch === 'string' && data.default_branch ? data.default_branch : 'main',
fetchedAt: new Date().toISOString()
};
} finally {
clearTimeout(timeout);
}
}
const repositories = new Set();
for (const file of await contentFiles(contentDir)) {
for (const repository of repositoriesFrom(await readFile(file, 'utf8'))) repositories.add(repository);
}
const cache = await readCache();
const now = Date.now();
let successful = 0;
for (const repository of repositories) {
const cached = cache.repositories[repository];
if (cached?.fetchedAt && now - Date.parse(cached.fetchedAt) < ttlMs) continue;
try {
cache.repositories[repository] = await fetchRepository(repository);
successful += 1;
console.log(`[github] fetched ${repository}`);
} catch (error) {
console.warn(`[github] ${repository}: ${error instanceof Error ? error.message : 'request failed'}; using ${cached ? 'cached data' : 'fallback values'}.`);
}
}
await mkdir(dirname(cachePath), { recursive: true });
await writeFile(cachePath, `${JSON.stringify({ version: 1, generatedAt: new Date().toISOString(), repositories: cache.repositories }, null, 2)}\n`);
await rm(join(root, '.astro'), { recursive: true, force: true });
console.log(`[github] cache ${cachePath} (${successful} request${successful === 1 ? '' : 's'}, ${repositories.size} repos)`);
+173
View File
@@ -0,0 +1,173 @@
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
const root = process.cwd();
const backupPath = process.env.TYPECHO_SQL_GZ ?? 'C:/Users/hi/Desktop/备份/www_luming_cool_2026-08-01_01-30-01_mysql_data.sql.gz';
const postsDir = path.join(root, 'src', 'content', 'posts');
const coversDir = path.join(root, 'public', 'images', 'typecho-covers');
function sqlString(value) {
if (value === 'NULL') return null;
if (value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1).replace(/\\([\\'"\\0bnrtZ])/g, (_, character) => ({ '\\': '\\', "'": "'", '"': '"', '0': '\0', b: '\b', n: '\n', r: '\r', t: '\t', Z: '\x1a' }[character] ?? character));
}
return value;
}
function splitSqlValues(input) {
const values = [];
let current = '';
let quote = false;
let escaped = false;
for (const character of input) {
if (quote) {
current += character;
if (escaped) escaped = false;
else if (character === '\\') escaped = true;
else if (character === "'") quote = false;
} else if (character === "'") {
quote = true;
current += character;
} else if (character === ',') {
values.push(sqlString(current.trim()));
current = '';
} else {
current += character;
}
}
values.push(sqlString(current.trim()));
return values;
}
function parseInsertRows(sql, table) {
const marker = `INSERT INTO \`${table}\` VALUES `;
const start = sql.indexOf(marker);
if (start < 0) return [];
let end = start + marker.length;
let scanQuote = false;
let scanEscaped = false;
for (; end < sql.length; end += 1) {
const character = sql[end];
if (scanQuote) {
if (scanEscaped) scanEscaped = false;
else if (character === '\\') scanEscaped = true;
else if (character === "'") scanQuote = false;
} else if (character === "'") {
scanQuote = true;
} else if (character === ';') {
break;
}
}
const input = sql.slice(start + marker.length, end);
const rows = [];
let row = '';
let depth = 0;
let rowQuote = false;
let rowEscaped = false;
for (const character of input) {
if (rowQuote) {
row += character;
if (rowEscaped) rowEscaped = false;
else if (character === '\\') rowEscaped = true;
else if (character === "'") rowQuote = false;
continue;
}
if (character === "'") rowQuote = true;
if (character === '(') depth += 1;
if (character === ')') depth -= 1;
if (depth > 0) row += character;
if (depth === 0 && row) {
rows.push(splitSqlValues(row.slice(1, -1)));
row = '';
}
}
return rows;
}
function tableColumns(sql, table) {
const tick = String.fromCharCode(96);
const match = sql.match(new RegExp('CREATE TABLE ' + tick + table + tick + ' \\(([\\s\\S]*?)\\)\\s+ENGINE', 'i'));
if (!match) throw new Error(`missing CREATE TABLE for ${table}`);
return [...match[1].matchAll(/^\s*`([^`]+)`/gm)].map((item) => item[1]);
}
function records(sql, table) {
const columns = tableColumns(sql, table);
return parseInsertRows(sql, table).map((values) => Object.fromEntries(columns.map((column, index) => [column, values[index] ?? null])));
}
function frontmatterRange(content) {
if (!content.startsWith('---')) return null;
const end = content.indexOf('\n---', 3);
return end < 0 ? null : [0, end + 4];
}
function yamlString(value) {
return JSON.stringify(value);
}
function findPosts() {
const files = [];
const visit = (directory) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) visit(target);
else if (/\.md$|\.mdx$/i.test(entry.name)) files.push(target);
}
};
visit(postsDir);
return files.map((file) => ({ file, content: fs.readFileSync(file, 'utf8') }));
}
async function main() {
const sql = zlib.gunzipSync(fs.readFileSync(backupPath)).toString('utf8');
const contents = records(sql, 'luming_contents');
const fields = records(sql, 'luming_fields');
const banners = new Map(fields.filter((field) => field.name === 'banner' && field.str_value).map((field) => [String(field.cid), field.str_value]));
const posts = findPosts();
const byTitle = new Map(posts.map((post) => [post.content.match(/^title:\s*["']?(.*?)["']?\s*$/m)?.[1]?.replace(/^['"]|['"]$/g, ''), post]));
fs.mkdirSync(coversDir, { recursive: true });
let downloaded = 0;
let failed = 0;
let changed = 0;
let matched = 0;
let existing = 0;
const coverFiles = new Map();
for (const [cid, url] of banners) {
const filename = path.basename(new URL(url).pathname);
const target = path.join(coversDir, filename);
if (!coverFiles.has(url)) {
try {
if (!fs.existsSync(target)) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
fs.writeFileSync(target, Buffer.from(await response.arrayBuffer()));
}
coverFiles.set(url, `/images/typecho-covers/${filename}`);
downloaded += 1;
} catch (error) {
coverFiles.set(url, url);
failed += 1;
console.error(`download failed: ${url}: ${error.message}`);
}
}
const content = contents.find((item) => String(item.cid) === cid);
const post = content && byTitle.get(content.title);
if (!post) continue;
matched += 1;
const range = frontmatterRange(post.content);
if (!range) continue;
if (/^cover:\s*/m.test(post.content.slice(...range))) {
existing += 1;
continue;
}
const insertion = `cover: ${yamlString(coverFiles.get(url))}\noriginalCover: ${yamlString(url)}\n`;
const next = `${post.content.slice(0, range[0])}${post.content.slice(range[0], range[1]).replace(/^---\n/, `---\n${insertion}`)}${post.content.slice(range[1])}`;
fs.writeFileSync(post.file, next, 'utf8');
changed += 1;
}
console.log(JSON.stringify({ sqlContents: contents.length, bannerFields: banners.size, matchedPosts: matched, existingCovers: existing, changedFiles: changed, successfulDownloads: downloaded, failedDownloads: failed, unresolvedBanners: banners.size - matched }, null, 2));
}
await main();
+210
View File
@@ -0,0 +1,210 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const root = process.cwd();
const backupDir = process.env.TYPECHO_BACKUP_DIR ?? 'C:/Users/hi/Desktop/备份';
const datPath = process.env.TYPECHO_DAT ?? path.join(backupDir, '20260801_www.luming.cool_6a6e0323b06da.dat');
const twikooExamplePath = path.join(backupDir, 'twikoo-comment (1).json');
const postsDir = path.join(root, 'src', 'content', 'posts');
const migrationDir = path.join(root, 'migration');
const commentsPath = path.join(migrationDir, 'twikoo-comments.json');
const MAGIC = Buffer.from('%TYPECHO_BACKUP_0001%');
const text = (value) => value == null ? null : value.toString('utf8');
const number = (value, fallback = 0) => Number.parseInt(text(value) ?? '', 10) || fallback;
const field = (record, name) => record.fields[name];
function parseBackup(filePath) {
const blob = fs.readFileSync(filePath);
if (!blob.subarray(0, MAGIC.length).equals(MAGIC)) throw new Error('invalid Typecho backup magic');
const records = [];
let pos = MAGIC.length;
while (pos < blob.length) {
if (blob.subarray(pos, pos + MAGIC.length).equals(MAGIC) && pos + MAGIC.length === blob.length) {
pos += MAGIC.length;
break;
}
const start = pos;
if (blob.length - pos < 8) throw new Error(`truncated record header at ${pos}`);
const tableId = blob.readUInt16LE(pos);
const schemaLength = blob.readUInt16LE(pos + 2);
const dataLength = blob.readUInt32LE(pos + 4);
pos += 8;
const schemaBytes = blob.subarray(pos, pos + schemaLength);
const schema = JSON.parse(schemaBytes.toString('utf8'));
pos += schemaLength;
const data = blob.subarray(pos, pos + dataLength);
if (data.length !== dataLength) throw new Error(`truncated record data at ${pos}`);
pos += dataLength;
const checksum = blob.subarray(pos, pos + 32).toString('ascii');
pos += 32;
const expected = crypto.createHash('md5').update(blob.subarray(start, start + 8 + schemaLength + dataLength)).digest('hex');
if (checksum !== expected) throw new Error(`checksum mismatch at ${start}`);
let cursor = 0;
const fields = {};
for (const [name, length] of Object.entries(schema)) {
if (length === null) { fields[name] = null; continue; }
const value = data.subarray(cursor, cursor + length);
if (value.length !== length) throw new Error(`field ${name} exceeds record at ${start}`);
cursor += length;
fields[name] = value;
}
if (cursor !== dataLength) throw new Error(`record length mismatch at ${start}`);
records.push({ tableId, fields, signature: Object.keys(schema).sort().join(',') });
}
if (pos !== blob.length) throw new Error(`unparsed trailing bytes at ${pos}`);
return records;
}
function dateFromUnix(value) {
const timestamp = number(value);
const milliseconds = timestamp < 100000000000 ? timestamp * 1000 : timestamp;
const date = new Date(milliseconds);
if (Number.isNaN(date.valueOf())) throw new Error(`invalid timestamp: ${timestamp}`);
return date.toISOString();
}
function yamlString(value) {
return JSON.stringify(value ?? '');
}
function englishSlug(raw, cid) {
const candidate = (raw ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return candidate || `post-${cid}`;
}
function uniqueSlug(raw, cid, used) {
const base = englishSlug(raw, cid);
let slug = base;
let suffix = 2;
while (used.has(slug)) slug = `${base}-${suffix++}`;
used.add(slug);
return slug;
}
function relationMap(records) {
const map = new Map();
for (const record of records) {
const cid = number(field(record, 'cid'));
const mid = number(field(record, 'mid'));
if (!cid || !mid) continue;
if (!map.has(cid)) map.set(cid, []);
map.get(cid).push(mid);
}
return map;
}
function classify(records) {
const allContents = records.filter((r) => ['cid', 'title', 'text'].every((key) => key in r.fields));
// Pages and drafts must also be materialized when comments or relationships
// refer to them; attachments are binary/media records, not post content.
const contents = allContents.filter((r) => text(field(r, 'type')) !== 'attachment');
const comments = records.filter((r) => ['coid', 'cid', 'author', 'text'].every((key) => key in r.fields));
const metas = records.filter((r) => ['mid', 'name', 'slug', 'type'].every((key) => key in r.fields));
const relations = records.filter((r) => ['cid', 'mid'].every((key) => key in r.fields) && !('title' in r.fields));
return { allContents, contents, comments, metas, relations };
}
function main() {
const records = parseBackup(datPath);
const { allContents, contents, comments, metas, relations } = classify(records);
const relation = relationMap(relations);
const metaById = new Map(metas.map((r) => [number(field(r, 'mid')), r]));
const used = new Set();
const postPaths = new Map();
const generated = [];
const categories = new Set();
const tags = new Set();
for (const record of contents) {
const cid = number(field(record, 'cid'));
const sourceSlug = text(field(record, 'slug'));
const slug = uniqueSlug(sourceSlug, cid, used);
const created = dateFromUnix(field(record, 'created'));
const modified = field(record, 'modified') == null ? null : dateFromUnix(field(record, 'modified'));
const date = new Date(created);
const year = String(date.getUTCFullYear());
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const route = `/posts/${year}/${month}/${slug}`;
const target = path.join(postsDir, year, month, `${slug}.md`);
const taxonomy = { categories: [], tags: [] };
for (const mid of relation.get(cid) ?? []) {
const meta = metaById.get(mid);
if (!meta) continue;
const name = text(field(meta, 'name')) ?? '';
const type = text(field(meta, 'type'));
if (type === 'category') taxonomy.categories.push(name), categories.add(name);
if (type === 'tag') taxonomy.tags.push(name), tags.add(name);
}
const description = ['description', 'summary', 'excerpt'].map((key) => text(field(record, key))).find(Boolean);
const lines = [
'---',
`title: ${yamlString(text(field(record, 'title')) ?? '')}`,
description ? `description: ${yamlString(description)}` : null,
`pubDate: ${yamlString(created)}`,
modified ? `updatedDate: ${yamlString(modified)}` : null,
taxonomy.categories.length ? 'categories:' : 'categories: []',
...taxonomy.categories.map((value) => ` - ${yamlString(value)}`),
taxonomy.tags.length ? 'tags:' : 'tags: []',
...taxonomy.tags.map((value) => ` - ${yamlString(value)}`),
sourceSlug && sourceSlug !== slug ? `originalSlug: ${yamlString(sourceSlug)}` : null,
`comments: ${number(field(record, 'commentsNum')) > 0 || comments.some((comment) => number(field(comment, 'cid')) === cid)}`,
'---',
'',
text(field(record, 'text')) ?? '',
''
].filter((line) => line !== null);
postPaths.set(cid, { route, slug, target, exists: fs.existsSync(target) });
if (!fs.existsSync(target)) {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, lines.join('\n'), 'utf8');
generated.push(path.relative(root, target));
}
}
const example = JSON.parse(fs.readFileSync(twikooExamplePath, 'utf8'));
if (!Array.isArray(example)) throw new Error('Twikoo example must be an array');
const twikooComments = comments.map((record, index) => {
const cid = number(field(record, 'cid'));
const target = postPaths.get(cid);
if (!target) throw new Error(`comment ${number(field(record, 'coid'), index)} points to missing post ${cid}`);
const created = number(field(record, 'created')) * (number(field(record, 'created')) < 100000000000 ? 1000 : 1);
const id = String(number(field(record, 'coid'), index + 1));
const mail = text(field(record, 'mail')) ?? '';
return {
_id: id,
uid: crypto.createHash('md5').update(`${text(field(record, 'author')) ?? ''}:${mail}`).digest('hex'),
nick: text(field(record, 'author')) ?? '匿名用户',
...(mail ? { mail, mailMd5: crypto.createHash('md5').update(mail.trim().toLowerCase()).digest('hex') } : {}),
...(text(field(record, 'url')) ? { link: text(field(record, 'url')) } : {}),
...(text(field(record, 'agent')) ? { ua: text(field(record, 'agent')) } : {}),
...(text(field(record, 'ip')) ? { ip: text(field(record, 'ip')) } : {}),
master: false,
url: `${target.route}/`,
href: `https://www.luming.cool${target.route}/`,
comment: text(field(record, 'text')) ?? '',
isSpam: text(field(record, 'status')) === 'spam',
created,
updated: created,
id
};
});
fs.mkdirSync(migrationDir, { recursive: true });
fs.writeFileSync(commentsPath, `${JSON.stringify(twikooComments, null, 2)}\n`, 'utf8');
generated.push(path.relative(root, commentsPath));
console.log(JSON.stringify({
records: records.length,
contents: contents.length,
generatedPosts: generated.filter((file) => file.replaceAll('\\', '/').startsWith('src/content/posts/')).length,
skippedExistingPosts: [...postPaths.values()].filter((post) => post.exists).length,
sourceContentTypes: Object.fromEntries([...new Set(allContents.map((record) => text(field(record, 'type')) ?? 'unknown'))].sort().map((type) => [type, allContents.filter((record) => (text(field(record, 'type')) ?? 'unknown') === type).length])),
categories: [...categories].sort(),
tags: [...tags].sort(),
comments: twikooComments.length,
twikooExampleComments: example.length,
generated
}, null, 2));
}
main();
+71
View File
@@ -0,0 +1,71 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
const root = process.cwd();
const now = new Date();
const year = String(now.getFullYear());
const month = String(now.getMonth() + 1).padStart(2, '0');
const postsDir = path.join(root, 'src', 'content', 'posts', year, month);
const rl = readline.createInterface({ input, output });
async function main() {
try {
let answers;
if (input.isTTY) {
answers = [
await rl.question('文章标题: '),
await rl.question('文章类名: '),
await rl.question('文章封面(可选,直接回车跳过): ')
];
} else {
answers = [];
for await (const line of rl) answers.push(line);
}
const [rawTitle = '', rawSlug = '', rawCover = ''] = answers;
const title = rawTitle.trim();
const slug = rawSlug.trim();
const cover = rawCover.trim();
if (!title) throw new Error('文章标题不能为空');
if (!slug) throw new Error('文章类名不能为空');
if (!/^[^\\/:*?"<>|]+$/.test(slug) || slug === '.' || slug === '..') {
throw new Error('文章类名只能是单个文件名,不能包含路径或 Windows 保留字符');
}
const target = path.join(postsDir, `${slug}.md`);
if (fs.existsSync(target)) throw new Error(`文章已存在: ${path.relative(root, target)}`);
const pubDate = now.toISOString();
const frontmatter = [
'---',
`title: ${JSON.stringify(title)}`,
`pubDate: ${JSON.stringify(pubDate)}`,
...(cover ? [`cover: ${JSON.stringify(cover)}`] : []),
'categories: []',
'tags: []',
'draft: true',
'---',
'',
''
].join('\n');
fs.mkdirSync(postsDir, { recursive: true });
fs.writeFileSync(target, frontmatter, 'utf8');
console.log(`已新建文章: ${path.relative(root, target)}`);
} catch (error) {
console.error(`新建文章失败: ${error.message}`);
process.exitCode = 1;
} finally {
rl.close();
}
}
main().catch((error) => {
console.error(`新建文章失败: ${error.message}`);
process.exitCode = 1;
});
+60
View File
@@ -0,0 +1,60 @@
import { siteConfig } from '@/site.config';
import type { BannerTextTone } from '@/types/config';
export interface TaxonomyBanner {
image?: string;
position?: string;
title?: string;
subtitle?: string;
textTone?: BannerTextTone;
}
// Keys are taxonomy slugs, as returned by slugify().
export const categoryBanners = {
'随笔': {
image: 'https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85?auto=format&fit=crop&w=2000&q=85',
position: 'center center',
subtitle: '留住日常里值得重读的片段。'
},
'写作': {
image: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?auto=format&fit=crop&w=2000&q=85',
position: 'center center',
subtitle: '把零散的念头整理成文字。'
}
} satisfies Record<string, TaxonomyBanner>;
export const tagBanners = {
'开始': {
image: 'https://images.unsplash.com/photo-1490730141103-6cac27aaab94?auto=format&fit=crop&w=2000&q=85',
position: 'center center'
},
'写作': {
image: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?auto=format&fit=crop&w=2000&q=85',
position: 'center center'
}
} satisfies Record<string, TaxonomyBanner>;
export interface ResolvedTaxonomyBanner {
title: string;
subtitle: string;
image?: string;
position: string;
textTone?: BannerTextTone;
}
export function resolveTaxonomyBanner(
banners: Readonly<Record<string, TaxonomyBanner>>,
slug: string,
fallback: Pick<ResolvedTaxonomyBanner, 'title' | 'subtitle'>
): ResolvedTaxonomyBanner {
const configured = Object.prototype.hasOwnProperty.call(banners, slug) ? banners[slug] : undefined;
const defaultImage = siteConfig.cards.defaultCovers[0];
return {
title: configured?.title ?? fallback.title,
subtitle: configured?.subtitle ?? fallback.subtitle,
image: configured?.image ?? siteConfig.banner.image ?? defaultImage,
position: configured?.position ?? siteConfig.banner.position,
textTone: configured?.textTone ?? siteConfig.banner.textTone
};
}
+39
View File
@@ -0,0 +1,39 @@
---
import { ArrowUp } from '@lucide/astro';
---
<button class="back-to-top" type="button" aria-label="返回顶部" title="返回顶部" data-back-to-top><ArrowUp aria-hidden="true" size={18} /></button>
<script>
const getButton = () => document.querySelector<HTMLButtonElement>('[data-back-to-top]');
const drawerOpen = () => {
const mobileDrawer = document.querySelector<HTMLElement>('[data-drawer]');
const tocDrawer = document.querySelector<HTMLElement>('[data-toc-drawer]');
return mobileDrawer?.getAttribute('aria-hidden') === 'false' || tocDrawer?.getAttribute('aria-hidden') === 'false';
};
const update = () => getButton()?.classList.toggle('is-visible', window.scrollY > 480 && !drawerOpen());
window.addEventListener('scroll', update, { passive: true });
document.addEventListener('click', (event) => {
if (event.target instanceof Element && event.target.closest('[data-back-to-top]')) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
const observeDrawers = () => {
['[data-drawer]', '[data-toc-drawer]'].forEach((selector) => {
const drawer = document.querySelector<HTMLElement>(selector);
if (drawer) new MutationObserver(update).observe(drawer, { attributes: true, attributeFilter: ['aria-hidden'] });
});
};
document.addEventListener('astro:page-load', () => {
observeDrawers();
update();
});
observeDrawers();
update();
</script>
<style>
.back-to-top { position: fixed; z-index: 30; right: max(14px, env(safe-area-inset-right)); bottom: max(14px, env(safe-area-inset-bottom)); display: grid; width: 42px; height: 42px; border: 1px solid var(--color-border); border-radius: 50%; background: var(--color-surface); color: var(--color-muted); box-shadow: var(--shadow-soft); cursor: pointer; opacity: 0; pointer-events: none; transform: translateY(8px); transition: opacity var(--transition-fast), transform var(--transition-fast); place-items: center; }
.back-to-top.is-visible { opacity: 1; pointer-events: auto; transform: translateY(0); }
@media (max-width: 600px) { .back-to-top { width: 38px; height: 38px; } }
</style>
+22
View File
@@ -0,0 +1,22 @@
---
import { siteConfig } from '@/site.config';
const containsChinese = (value: string) => /[\u3400-\u9fff]/.test(value);
---
<footer class="w-full border-t border-line bg-raised px-5 py-5 text-center font-mono text-xs leading-6 text-subtle" data-pagefind-ignore="all">
<div class="mx-auto max-w-[73.125rem]">
{siteConfig.footer.links.length > 0 && <nav aria-label="页脚导航">{siteConfig.footer.links.map((item, index) => <Fragment>{index > 0 && ' · '}<a data-text-link href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined}><span class:list={{ 'footer-chinese': containsChinese(item.label) }} lang={containsChinese(item.label) ? 'zh-CN' : undefined}>{item.label}</span></a></Fragment>)}</nav>}
{siteConfig.footer.copyright && <p class="m-0">{siteConfig.footer.copyright}</p>}
{siteConfig.footer.note && <p class="footer-chinese m-0" lang="zh-CN">{siteConfig.footer.note}</p>}
{siteConfig.footer.theme && <p class="m-0"><a data-text-link href={siteConfig.footer.theme.href} target={siteConfig.footer.theme.external ? '_blank' : undefined} rel={siteConfig.footer.theme.external ? 'noreferrer' : undefined}>{siteConfig.footer.theme.label}</a></p>}
<p class="m-0">Powered By Astro. Theme <a data-text-link href="https://src.luming.cool/riseforever2026/Eidolon">Eidolon</a></p>
</div>
</footer>
<style>
.footer-chinese {
font-family: var(--mirages-font-sans);
}
</style>
+88
View File
@@ -0,0 +1,88 @@
<script>
import 'photoswipe/style.css';
const imageSelector = '.prose img';
const excludedImageAncestors = '.mermaid, .katex, pre, [data-lightbox-ignore]';
let lightboxPromise: Promise<import('photoswipe/lightbox').default> | undefined;
const isLightboxImage = (image: HTMLImageElement, prose: HTMLElement) =>
prose.contains(image) && !image.closest(excludedImageAncestors) && !image.closest('a');
const getImages = (prose: HTMLElement) => [...prose.querySelectorAll<HTMLImageElement>('img')]
.filter((image) => isLightboxImage(image, prose));
const getSize = (image: HTMLImageElement) => {
const width = Number(image.dataset.originalWidth) || Number(image.getAttribute('width')) || image.naturalWidth || image.clientWidth || 1200;
const height = Number(image.dataset.originalHeight) || Number(image.getAttribute('height')) || image.naturalHeight || image.clientHeight || Math.round(width * 2 / 3);
return { width, height };
};
const open = async (image: HTMLImageElement, images: HTMLImageElement[]) => {
const index = images.indexOf(image);
const src = image.currentSrc || image.src;
if (index < 0 || !src) return;
const items = images.map((item) => ({
...getSize(item),
src: item.dataset.originalSrc || item.currentSrc || item.src,
alt: item.alt
}));
lightboxPromise ??= import('photoswipe/lightbox').then(({ default: PhotoSwipeLightbox }) => {
const lightbox = new PhotoSwipeLightbox({
pswpModule: () => import('photoswipe'),
bgClickAction: 'close'
});
lightbox.init();
return lightbox;
});
try {
const lightbox = await lightboxPromise;
lightbox.loadAndOpen(index, items);
} catch (error) {
lightboxPromise = undefined;
console.error('Unable to open image lightbox', error);
}
};
const init = () => {
document.querySelectorAll<HTMLElement>('.prose').forEach((prose) => {
if (prose.dataset.lightboxReady === 'true') return;
prose.dataset.lightboxReady = 'true';
const prepareImage = (image: HTMLImageElement) => {
if (!isLightboxImage(image, prose)) return;
image.classList.add('lightbox-image');
image.tabIndex = image.tabIndex >= 0 ? image.tabIndex : 0;
image.setAttribute('role', 'button');
image.setAttribute('aria-label', image.alt ? `查看大图:${image.alt}` : '查看大图');
};
getImages(prose).forEach(prepareImage);
const observer = new MutationObserver(() => getImages(prose).forEach(prepareImage));
observer.observe(prose, { childList: true, subtree: true });
prose.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const image = target.closest<HTMLImageElement>(imageSelector);
if (!image || !isLightboxImage(image, prose)) return;
event.preventDefault();
event.stopPropagation();
void open(image, getImages(prose));
});
prose.addEventListener('keydown', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const image = target.closest<HTMLImageElement>(imageSelector);
if (!image || !isLightboxImage(image, prose) || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
void open(image, getImages(prose));
});
});
};
init();
document.addEventListener('astro:page-load', init);
</script>
<style is:global>
.prose img.lightbox-image { cursor: zoom-in; }
.prose img.lightbox-image:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; }
</style>
+129
View File
@@ -0,0 +1,129 @@
---
import { siteConfig } from '@/site.config';
import { slugify } from '@/lib/content';
import type { BannerTextTone } from '@/types/config';
interface Props {
article?: {
title: string;
date: { year: string; month: string; day: string };
categories: string[];
image?: string;
textTone?: BannerTextTone;
};
banner?: {
title: string;
subtitle?: string;
image?: string;
position?: string;
textTone?: BannerTextTone;
};
about?: boolean;
textTone?: BannerTextTone;
}
const { article, banner, about = false } = Astro.props;
const enabled = Boolean(about || article || banner || siteConfig.banner.enabled);
const image = banner ? banner.image : article?.image ?? (about ? undefined : siteConfig.banner.image);
const title = about ? siteConfig.site.author.name : (banner?.title ?? article?.title ?? siteConfig.banner.title);
const subtitle = banner?.subtitle ?? siteConfig.banner.subtitle;
const position = banner?.position ?? siteConfig.banner.position;
---
{enabled && <section
class="relative top-0 flex min-h-50 items-center justify-center overflow-hidden bg-raised pt-16 text-center text-foreground md:pt-16"
style={`background-position: ${position}; --mobile-height: ${siteConfig.banner.mobileHeightVh}vh; --desktop-height: ${siteConfig.banner.desktopHeightVh}vh; --overlay: ${siteConfig.banner.overlay}`}
data-banner-image={image || undefined}
data-banner-tone={image ? 'light' : undefined}
aria-labelledby="masthead-title"
data-pagefind-ignore="all"
data-banner
>
{image && <div
class="absolute inset-0 z-0 bg-cover bg-no-repeat"
style={`background-image: url("${image}"); background-position: ${position}`}
aria-hidden="true"
></div>}
{image && <div class="absolute inset-0 z-[1]" style={`background-color: rgb(0 0 0 / ${siteConfig.banner.overlay})`} aria-hidden="true"></div>}
<div class="relative z-10 flex w-[calc(100%-1.75rem)] flex-col items-center justify-center">
{about && <img
class:list={['mb-5 size-[7rem] rounded-full border-4 border-white/85 object-cover shadow-lg md:size-[9.375rem]', { 'about-avatar': siteConfig.site.author.rotateAvatar }]}
src={siteConfig.site.author.avatar}
alt={`${siteConfig.site.author.name} 的头像`}
width="150"
height="150"
loading="eager"
fetchpriority="high"
/>}
<h1 class="m-0 text-[2rem] leading-tight font-light tracking-[0] md:text-[2.5rem]" id="masthead-title">{title}</h1>
{article ? <p class="masthead-meta mt-3 mb-0 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-[0.9375rem] md:text-base">
<span class="masthead-date"><span>{article.date.year}</span><strong>年</strong><span>{article.date.month}</span><strong>月</strong><span>{article.date.day}</span><strong>日</strong></span>
{article.categories.length > 0 && <span class="masthead-separator" aria-hidden="true">·</span>}
{article.categories.map((category, index) => <Fragment>
{index > 0 && <span class="masthead-separator" aria-hidden="true">/</span>}
<a class="masthead-category" data-text-link href={`/categories/${slugify(category)}/`}>{category}</a>
</Fragment>)}
</p> : !about && subtitle && <p class="mt-3 mb-0 text-[0.9375rem] md:text-base">{subtitle}</p>}
</div>
</section>}
{enabled && image && <script>
const updateBannerTone = () => {
const masthead = document.querySelector<HTMLElement>('[data-banner][data-banner-image]');
const imageUrl = masthead?.dataset.bannerImage;
if (!masthead || !imageUrl) return;
const source = new Image();
source.crossOrigin = 'anonymous';
source.onload = () => {
try {
const canvas = document.createElement('canvas');
const size = 32;
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) throw new Error('Canvas context unavailable');
context.drawImage(source, 0, 0, size, size);
const pixels = context.getImageData(0, 0, size, size).data;
let luma = 0;
let weight = 0;
for (let index = 0; index < pixels.length; index += 4) {
const alpha = pixels[index + 3] / 255;
luma += (0.2126 * pixels[index] + 0.7152 * pixels[index + 1] + 0.0722 * pixels[index + 2]) * alpha;
weight += alpha;
}
masthead.dataset.bannerTone = weight && luma / weight >= 150 ? 'dark' : 'light';
} catch {
masthead.dataset.bannerTone = 'light';
}
};
source.onerror = () => { masthead.dataset.bannerTone = 'light'; };
source.src = imageUrl;
};
document.addEventListener('astro:page-load', updateBannerTone);
updateBannerTone();
</script>}
<style>
section { height: var(--mobile-height); }
:global(:root[data-font='serif']) section h1 { font-weight: 700; }
section[data-banner-image][data-banner-tone='dark'] h1,
section[data-banner-image][data-banner-tone='dark'] p,
section[data-banner-image][data-banner-tone='dark'] .masthead-meta { color: #201e1d; text-shadow: 0 1px 2px rgb(255 255 255 / 18%); }
section[data-banner-image][data-banner-tone='light'] h1,
section[data-banner-image][data-banner-tone='light'] p,
section[data-banner-image][data-banner-tone='light'] .masthead-meta { color: #fff; text-shadow: 0 1px 2px rgb(0 0 0 / 24%); }
section[data-banner-image][data-banner-tone='dark'] .masthead-meta a,
section[data-banner-image][data-banner-tone='light'] .masthead-meta a { color: inherit; }
.masthead-meta a { color: inherit; }
.masthead-meta a::after { border-color: currentColor; }
.masthead-date,
.masthead-separator { font-family: Consolas, Menlo, Monaco, 'lucida_console', 'Liberation_Mono', 'Courier_New', 'andale_mono', monospaceX, monospace, sans-serif; }
.masthead-date strong { font-family: var(--mirages-font-ui); font-weight: 400; }
.masthead-category { font-family: var(--mirages-font-ui); }
.about-avatar { animation: avatar-rotate 8s ease-in-out infinite alternate; }
@keyframes avatar-rotate { from { transform: rotate(-2deg); } to { transform: rotate(2deg); } }
@media (min-width: 48rem) { section { height: var(--desktop-height); } }
@media (prefers-reduced-motion: reduce) { .about-avatar { animation: none; transform: none; } }
</style>
+22
View File
@@ -0,0 +1,22 @@
---
interface Props { enabled: boolean; }
const { enabled } = Astro.props;
---
{enabled && <script>
const blocks = [...document.querySelectorAll('pre code.language-mermaid, pre[data-language="mermaid"] code')];
if (blocks.length) {
const mermaid = await import('mermaid');
const dark = document.documentElement.dataset.theme === 'dark' || (document.documentElement.dataset.theme === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
mermaid.default.initialize({ startOnLoad: false, theme: dark ? 'dark' : 'default', securityLevel: 'strict' });
await Promise.all(blocks.map(async (block, index) => {
const wrapper = block.parentElement;
if (!wrapper) return;
const container = document.createElement('div');
container.className = 'mermaid';
container.id = `mermaid-${index}`;
container.textContent = block.textContent ?? '';
wrapper.replaceWith(container);
await mermaid.default.run({ nodes: [container] });
}));
}
</script>}
+189
View File
@@ -0,0 +1,189 @@
---
import { Check, ChevronDown } from '@lucide/astro';
import { siteConfig } from '@/site.config';
import { deriveTaxonomy, getPublicPosts, slugify } from '@/lib/content';
import ThemeSwitcher from './ThemeSwitcher.astro';
import Search from './Search.astro';
import ToolbarIcon from './ToolbarIcon.astro';
const pageItems = siteConfig.navigation.filter((item) => item.href !== '/');
const categories = deriveTaxonomy(await getPublicPosts(), 'categories');
const toolbarItems = siteConfig.toolbarItems.filter((item) => item.type !== 'search' || siteConfig.search.provider === 'pagefind');
const sidebarToolbarItems = toolbarItems.filter((item) => item.type !== 'search');
const hasSidebarSearch = siteConfig.search.provider === 'pagefind';
interface Props { overlay?: boolean; }
const { overlay = false } = Astro.props;
---
<header class:list={['fixed inset-x-0 top-0 z-40 hidden h-[4rem] border-b border-line/60 bg-nav text-foreground shadow-[0_2px_10px_rgb(0_0_0/8%)] backdrop-blur-md md:flex', { 'navbar-over-banner': overlay }]} data-desktop-navbar data-pagefind-ignore="all">
<div class="mx-auto flex h-full min-w-0 w-[calc(100%-2.5rem)] max-w-[1170px] items-center gap-[1.75rem]">
<a class="shrink-0 text-[1.25rem] font-bold text-current no-underline hover:text-mirages-accent" href="/" aria-label={`${siteConfig.site.title} 首页`}>{siteConfig.site.title}</a>
<nav class="flex min-w-0 items-center gap-[1.5rem]" aria-label="主导航">
<details class="group relative" data-category-menu>
<summary class="flex cursor-pointer list-none items-center gap-1 text-[0.9375rem] text-current/80 hover:text-mirages-accent [&::-webkit-details-marker]:hidden">
<span>分类</span><ChevronDown class="transition-transform duration-150 group-open:rotate-180" aria-hidden="true" size="1em" />
</summary>
<div class="absolute top-[calc(100%+20px)] left-0 z-50 min-w-44 border border-line bg-page py-1 text-foreground shadow-mirages">
{categories.length > 0 ? categories.map((category) => (
<a class="flex items-center justify-between gap-5 px-3 py-2 text-sm no-underline hover:bg-raised" href={`/categories/${slugify(category.name)}/`}>
<span>{category.name}</span><span class="text-xs text-subtle">{category.count}</span>
</a>
)) : <span class="block px-3 py-2 text-sm text-subtle">暂无分类</span>}
</div>
</details>
{pageItems.map((item) => <a class="text-[0.9375rem] text-current/80 no-underline hover:text-mirages-accent" href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined}>{item.label}</a>)}
</nav>
<div class="ml-auto flex shrink-0 items-center" data-navbar-tools>
{toolbarItems.map((item) => (
<div class="relative flex items-center" data-toolbar-item={item.type} data-toolbar-name={item.name}>
{item.type === 'search' && <Search />}
{item.type === 'settings' && <ThemeSwitcher />}
{item.type === 'link' && <a class="grid size-[2.5rem] shrink-0 place-items-center text-current/80 no-underline hover:bg-current/10 hover:text-current" href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined} aria-label={item.name} title={item.name}><ToolbarIcon icon={item.icon} /><span class="sr-only">{item.name}</span></a>}
{item.type === 'rss' && <button class="relative grid size-[2.5rem] shrink-0 cursor-pointer place-items-center border-0 bg-transparent p-0 text-current/80 hover:bg-current/10 hover:text-current" type="button" aria-label={item.name} title={item.name} data-rss-copy data-rss-url={item.href}><span class="grid place-items-center leading-none" data-rss-default-icon><ToolbarIcon icon={item.icon} /></span><Check class="hidden" aria-hidden="true" size="1em" data-rss-success-icon /><span class="pointer-events-none absolute top-[calc(100%+0.5rem)] left-1/2 hidden -translate-x-1/2 whitespace-nowrap rounded-[4px] bg-foreground px-2 py-1 text-xs text-page shadow-mirages" role="status" data-rss-feedback>已复制</span><span class="sr-only">{item.name}</span></button>}
</div>
))}
</div>
</div>
</header>
<button class="fixed top-[0.75rem] left-[0.75rem] z-[70] h-[2.5rem] cursor-pointer rounded-full border border-line/70 bg-nav px-[1.25rem] text-xs font-semibold tracking-[0.08em] text-foreground shadow-[0_2px_8px_rgb(0_0_0/24%)] backdrop-blur-sm transition-transform duration-200 md:hidden" id="toggle-nav" type="button" aria-label="打开导航" aria-controls="site-navigation" aria-expanded="false" data-open-drawer><span>MENU</span></button>
<div class="pointer-events-none fixed inset-0 z-50 bg-black/40 opacity-0 transition-opacity duration-200 md:hidden" aria-hidden="true" data-drawer-backdrop></div>
<aside class="fixed inset-y-0 left-0 z-[60] flex w-[17.5rem] max-w-[84vw] -translate-x-full flex-col bg-page text-foreground shadow-none transition-transform duration-200 md:hidden" id="site-navigation" aria-label="移动端导航" aria-hidden="true" data-drawer data-pagefind-ignore="all">
<div class="min-h-0 flex-1 overflow-y-auto px-5 pt-9">
<div class="mb-6 flex justify-center" data-sidebar-avatar>
<a class="grid size-24 place-items-center overflow-hidden rounded-full border-2 border-line bg-raised text-2xl font-bold no-underline shadow-mirages" href="/about/" aria-label="关于作者">
<img class="size-full object-cover" src={siteConfig.site.author.avatar} alt={`${siteConfig.site.author.name} 的头像`} width="100" height="100" />
</a>
</div>
{hasSidebarSearch && <div class="mb-5" data-sidebar-search><Search mobile /></div>}
<nav aria-label="侧栏菜单">
<ul class="m-0 list-none p-0 text-[0.9375rem]">
<li><a class="block border-b border-line px-2 py-3 text-center no-underline" href="/">首页</a></li>
<li>
<button class="block w-full cursor-pointer border-0 border-b border-line bg-transparent px-2 py-3 text-center text-inherit" type="button" aria-expanded="false" aria-controls="mobile-category-menu" data-mobile-category-trigger>分类</button>
<details id="mobile-category-menu" data-mobile-category-menu>
<summary class="sr-only" aria-hidden="true">分类</summary>
<ul class="m-0 list-none p-0">
{categories.length > 0 ? categories.map((category) => <li><a class="ml-3 block border-l-2 border-line/60 bg-raised/45 px-3 py-2 text-center text-[0.8125rem] text-subtle no-underline hover:bg-raised hover:text-foreground" href={`/categories/${slugify(category.name)}/`}>{category.name}</a></li>) : <li class="ml-3 border-l-2 border-line/60 bg-raised/45 px-3 py-2 text-center text-[0.8125rem] text-subtle">暂无分类</li>}
</ul>
</details>
</li>
{pageItems.map((item) => <li><a class="block border-b border-line px-2 py-3 text-center no-underline" href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined}>{item.label}</a></li>)}
</ul>
</nav>
</div>
<div class="relative flex min-h-[4rem] items-center justify-center gap-[0.75rem] border-t border-line bg-raised px-[1rem]" data-sidebar-toolbar>
{sidebarToolbarItems.map((item) => (
<div class="static flex items-center" data-toolbar-item={item.type} data-toolbar-name={item.name}>
{item.type === 'settings' && <ThemeSwitcher placement="up" />}
{item.type === 'link' && <a class="grid size-[2.5rem] shrink-0 place-items-center text-foreground no-underline hover:bg-raised" href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined} aria-label={item.name} title={item.name}><ToolbarIcon icon={item.icon} /><span class="sr-only">{item.name}</span></a>}
{item.type === 'rss' && <button class="relative grid size-[2.5rem] shrink-0 cursor-pointer place-items-center border-0 bg-transparent p-0 text-foreground hover:bg-raised" type="button" aria-label={item.name} title={item.name} data-rss-copy data-rss-url={item.href}><span class="grid place-items-center leading-none" data-rss-default-icon><ToolbarIcon icon={item.icon} /></span><Check class="hidden" aria-hidden="true" size="1em" data-rss-success-icon /><span class="pointer-events-none absolute bottom-[calc(100%+0.5rem)] left-1/2 hidden -translate-x-1/2 whitespace-nowrap rounded-[4px] bg-foreground px-2 py-1 text-xs text-page shadow-mirages" role="status" data-rss-feedback>已复制</span><span class="sr-only">{item.name}</span></button>}
</div>
))}
</div>
</aside>
<script>
const setOpen = (open: boolean, restore = false) => {
const drawer = document.querySelector<HTMLElement>('[data-drawer]');
const backdrop = document.querySelector<HTMLElement>('[data-drawer-backdrop]');
const openButton = document.querySelector<HTMLButtonElement>('[data-open-drawer]');
drawer?.classList.toggle('-translate-x-full', !open);
openButton?.classList.toggle('is-drawer-open', open);
openButton?.setAttribute('aria-label', open ? '关闭导航' : '打开导航');
backdrop?.classList.toggle('pointer-events-none', !open);
backdrop?.classList.toggle('opacity-0', !open);
openButton?.setAttribute('aria-expanded', String(open));
drawer?.setAttribute('aria-hidden', String(!open));
document.body.classList.toggle('overflow-hidden', open);
if (restore) openButton?.focus();
};
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
const openButton = target.closest<HTMLButtonElement>('[data-open-drawer]');
const backdrop = target.closest<HTMLElement>('[data-drawer-backdrop]');
const drawer = target.closest<HTMLElement>('[data-drawer]');
const categoryTrigger = target.closest<HTMLButtonElement>('[data-mobile-category-trigger]');
const rssButton = target.closest<HTMLButtonElement>('[data-rss-copy]');
if (rssButton) {
const successIcon = rssButton.querySelector<HTMLElement>('[data-rss-success-icon]');
const defaultIcon = rssButton.querySelector<HTMLElement>('[data-rss-default-icon]');
const feedback = rssButton.querySelector<HTMLElement>('[data-rss-feedback]');
void navigator.clipboard.writeText(new URL(rssButton.dataset.rssUrl ?? '', document.baseURI).href).then(() => {
window.clearTimeout(Number(rssButton.dataset.rssResetTimer));
defaultIcon?.classList.add('hidden');
successIcon?.classList.remove('hidden');
feedback?.classList.remove('hidden');
rssButton.dataset.rssResetTimer = String(window.setTimeout(() => {
defaultIcon?.classList.remove('hidden');
successIcon?.classList.add('hidden');
feedback?.classList.add('hidden');
delete rssButton.dataset.rssResetTimer;
}, 1200));
});
} else if (openButton) {
setOpen(openButton.getAttribute('aria-expanded') !== 'true', true);
} else if (backdrop || (drawer && target.closest('a'))) {
setOpen(false, true);
} else if (categoryTrigger) {
const menu = document.getElementById(categoryTrigger.getAttribute('aria-controls') ?? '') as HTMLDetailsElement | null;
if (menu) menu.open = !menu.open;
}
});
document.addEventListener('toggle', (event) => {
const menu = event.target as HTMLDetailsElement;
if (!menu.matches('[data-mobile-category-menu]')) return;
document.querySelector<HTMLButtonElement>(`[aria-controls="${menu.id}"]`)?.setAttribute('aria-expanded', String(menu.open));
}, true);
document.addEventListener('keydown', (event) => {
const openButton = document.querySelector<HTMLButtonElement>('[data-open-drawer]');
if (event.key === 'Escape' && openButton?.getAttribute('aria-expanded') === 'true') setOpen(false, true);
const menu = event.target instanceof Element ? event.target.closest<HTMLDetailsElement>('[data-category-menu]') : null;
if (event.key === 'Escape' && menu) {
menu.removeAttribute('open');
menu.querySelector<HTMLElement>('summary')?.focus();
}
});
document.addEventListener('pointerdown', (event) => {
document.querySelectorAll<HTMLDetailsElement>('[data-category-menu]').forEach((menu) => {
if (!menu.contains(event.target as Node)) menu.removeAttribute('open');
});
});
document.addEventListener('astro:before-preparation', () => setOpen(false));
</script>
<style is:global>
[data-open-drawer] {
font-family: Arial, Helvetica, sans-serif;
}
[data-open-drawer].is-drawer-open {
transform: translateX(14.25rem);
}
.navbar-over-banner {
border-color: color-mix(in srgb, var(--color-border) 70%, transparent);
background: color-mix(in srgb, var(--color-bg) 82%, transparent);
box-shadow: 0 2px 10px color-mix(in srgb, var(--color-text) 12%, transparent);
}
.navbar-over-banner [data-navbar-tools] > [data-toolbar-item] > a:hover,
.navbar-over-banner [data-navbar-tools] > [data-toolbar-item] > details > summary:hover,
.navbar-over-banner [data-navbar-tools] > [data-toolbar-item] > [data-search-root] > button:hover {
background: color-mix(in srgb, currentColor 16%, transparent);
}
.navbar-over-banner [data-navbar-tools] .navbar-search-container > .search-form {
border-color: color-mix(in srgb, var(--color-text) 50%, transparent);
background: color-mix(in srgb, var(--color-bg) 92%, transparent);
color: var(--color-text);
}
.navbar-over-banner [data-navbar-tools] .navbar-search-container > .search-form input::placeholder {
color: color-mix(in srgb, currentColor 75%, transparent);
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<div class="navigation-progress" data-navigation-progress aria-hidden="true" transition:persist="navigation-progress">
<span class="navigation-progress__bar"></span>
</div>
<script>
let timer: number | undefined;
let completionAnimation: Animation | undefined;
let hideAnimation: Animation | undefined;
const getElements = () => {
const root = document.querySelector<HTMLElement>('[data-navigation-progress]');
const bar = root?.querySelector<HTMLElement>('.navigation-progress__bar');
return { root, bar };
};
const setProgress = (bar: HTMLElement, value: number) => {
if (bar) bar.style.transform = `scaleX(${value})`;
};
const start = () => {
const { root, bar } = getElements();
if (!root || !bar) return;
window.clearInterval(timer);
completionAnimation?.cancel();
hideAnimation?.cancel();
root.classList.remove('is-finishing', 'is-hiding');
root.classList.add('is-visible');
bar.dataset.progress = '0';
bar.style.transition = '';
setProgress(bar, 0);
timer = window.setInterval(() => {
const currentElements = getElements();
if (!currentElements.bar) return;
const currentBar = currentElements.bar;
const current = Number.parseFloat(currentBar.dataset.progress ?? '0');
const next = current + (0.99 - current) * 0.025;
currentBar.dataset.progress = String(next);
setProgress(currentBar, next);
}, 240);
};
const finish = () => {
const { root, bar } = getElements();
if (!root || !bar) return;
window.clearInterval(timer);
completionAnimation?.cancel();
hideAnimation?.cancel();
const current = Number.parseFloat(bar.dataset.progress ?? '0');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const duration = reducedMotion ? 1 : 300;
root.classList.add('is-finishing');
root.style.transform = 'translateY(0)';
bar.style.transition = 'none';
setProgress(bar, current);
completionAnimation = bar.animate(
[{ transform: `scaleX(${current})` }, { transform: 'scaleX(1)' }],
{ duration, easing: 'cubic-bezier(0.33, 1, 0.68, 1)', fill: 'forwards' }
);
completionAnimation.onfinish = () => {
const currentElements = getElements();
if (!currentElements.root || !currentElements.bar || currentElements.bar !== bar) return;
bar.dataset.progress = '1';
bar.style.transform = 'scaleX(1)';
currentElements.root.classList.add('is-hiding');
hideAnimation = currentElements.root.animate(
[{ transform: 'translateY(0)' }, { transform: 'translateY(-100%)' }],
{ duration: reducedMotion ? 1 : 300, easing: 'cubic-bezier(0.33, 1, 0.68, 1)', fill: 'forwards' }
);
hideAnimation.onfinish = () => {
const latestElements = getElements();
if (!latestElements.root || !latestElements.bar || latestElements.bar !== bar) return;
latestElements.root.classList.remove('is-visible', 'is-finishing', 'is-hiding');
latestElements.root.style.transform = '';
latestElements.bar.dataset.progress = '0';
latestElements.bar.style.transition = '';
setProgress(latestElements.bar, 0);
latestElements.bar.getAnimations().forEach((animation) => animation.cancel());
latestElements.root.getAnimations().forEach((animation) => animation.cancel());
};
};
};
document.addEventListener('astro:before-preparation', start);
document.addEventListener('astro:page-load', finish);
</script>
<style is:global>
.navigation-progress {
position: fixed;
z-index: 999;
top: 0;
right: 0;
left: 0;
height: 3px;
min-height: 3px;
max-height: 3px;
overflow: hidden;
box-sizing: border-box;
display: block;
pointer-events: none;
contain: strict;
transform: translateY(-100%);
transition: transform 240ms cubic-bezier(0.33, 1, 0.68, 1);
}
.navigation-progress.is-visible { transform: translateY(0); }
.navigation-progress.is-hiding { transform: translateY(-100%); }
.navigation-progress__bar {
display: block;
width: 100%;
height: 100%;
min-height: 3px;
max-height: 3px;
background: var(--color-accent);
box-shadow: 0 3px 8px rgb(0 0 0 / 35%);
transform: scaleX(0);
transform-origin: left center;
transition: transform 240ms linear;
}
.navigation-progress.is-finishing .navigation-progress__bar {
transition: transform 700ms cubic-bezier(0.33, 1, 0.68, 1);
}
@media (prefers-reduced-motion: reduce) {
.navigation-progress,
.navigation-progress__bar { transition: none; }
}
</style>
+10
View File
@@ -0,0 +1,10 @@
---
interface Props { current: number; total: number; basePath: string; }
const { current, total, basePath } = Astro.props;
const path = (page: number) => page === 1 ? `${basePath}/` : `${basePath}/page/${page}/`;
const buttonClass = 'rounded-[4px] border border-mirages-accent px-7 py-2 text-sm font-bold text-mirages-accent no-underline hover:bg-mirages-accent hover:text-white';
---
{total > 1 && <nav class="mt-8 flex min-h-10 items-center justify-between gap-4" aria-label="分页">
{current > 1 ? <a class={buttonClass} href={path(current - 1)} aria-label="上一页">上一页</a> : <span></span>}
{current < total && <a class={buttonClass} href={path(current + 1)} aria-label="下一页">下一页</a>}
</nav>}
+28
View File
@@ -0,0 +1,28 @@
---
import type { PostEntry } from '@/lib/content';
import { getPostCardImage, getPostPath, getPostTaxonomy } from '@/lib/content';
import { siteConfig } from '@/site.config';
interface Props { post: PostEntry; }
const { post } = Astro.props;
const postPath = getPostPath(post);
const taxonomy = getPostTaxonomy(post);
const categories = taxonomy.categories.join(', ');
const date = [post.data.pubDate.getFullYear(), post.data.pubDate.getMonth() + 1, post.data.pubDate.getDate()]
.map((part, index) => index === 0 ? String(part) : String(part).padStart(2, '0'))
.join('-');
const image = getPostCardImage(post, siteConfig.cards.defaultCovers, siteConfig.site.url);
---
<article class="py-[0.9375rem] md:py-[0.9375rem]" data-pagefind-ignore="index">
<a class="group relative block h-50 overflow-hidden rounded-[0.3125rem] bg-[#444] text-white no-underline shadow-sm transition-[transform,box-shadow] duration-300 ease-in-out hover:[transform:translateY(-4px)_scale(1.05)] hover:text-white hover:shadow-[0_22px_43px_rgb(0_0_0/15%)] max-[21rem]:h-42 md:h-62" href={postPath}>
{image && <img class="absolute inset-0 size-full object-cover" src={image} alt="" loading="lazy" />}
<span class="absolute inset-0 z-10 bg-black/25" aria-hidden="true"></span>
<div class="absolute inset-0 z-20 flex flex-col items-center justify-center px-4 py-4 text-center md:px-6">
<h2 class="post-card-title m-0 max-w-[90%] [overflow-wrap:anywhere] font-sans text-[1.5625rem] leading-tight font-normal tracking-[0]" data-pagefind-weight="3">{post.data.title}</h2>
<p class="mt-3 mb-0 max-w-[90%] [overflow-wrap:anywhere] text-[0.8125rem] leading-relaxed font-normal text-[#eee] [font-family:Consolas,Menlo,Monaco,'lucida_console','Liberation_Mono','Courier_New','andale_mono',monospaceX,monospace,sans-serif]"><time datetime={post.data.pubDate.toISOString()}>{date}</time>{categories && <><span> · </span><span class="[font-family:var(--mirages-font-ui)]">{categories}</span></>}</p>
</div>
</a>
</article>
<style>
:global(:root[data-font='serif']) .post-card-title { font-weight: 500; }
</style>
+15
View File
@@ -0,0 +1,15 @@
---
import type { PostEntry } from '@/lib/content';
import PostCard from './PostCard.astro';
import Pagination from './Pagination.astro';
interface Props { posts: PostEntry[]; title?: string; description?: string; current?: number; total?: number; basePath?: string; }
const { posts, title, description, current = 1, total = 1, basePath = '' } = Astro.props;
---
<section class="mx-auto w-full px-5 pt-8 pb-14 md:max-w-[752px] md:px-0 md:pt-10 min-[1302px]:max-w-[896px] min-[1600px]:max-w-[928px] min-[1800px]:max-w-[992px] min-[2000px]:max-w-[1024px] min-[2400px]:max-w-[1056px]" aria-labelledby={title ? 'list-title' : undefined}>
{title && <header class="mb-5 border-b border-line pb-3">
<h1 class="m-0 text-xl leading-tight font-normal tracking-[0]" id="list-title">{title}</h1>
{description && <p class="mt-1 mb-0 text-sm text-subtle">{description}</p>}
</header>}
{posts.length > 0 ? posts.map((post) => <PostCard post={post} />) : <p class="py-8 text-subtle">暂时还没有已发布的文章。</p>}
<Pagination current={current} total={total} basePath={basePath} />
</section>
+14
View File
@@ -0,0 +1,14 @@
---
import type { PostEntry } from '@/lib/content';
import { getPostTaxonomy, slugify } from '@/lib/content';
interface Props { post: PostEntry; compact?: boolean; showTags?: boolean; }
const { post, compact = false, showTags = true } = Astro.props;
const taxonomy = getPostTaxonomy(post);
const date = post.data.pubDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'short', day: 'numeric' });
---
<div class="flex flex-wrap items-center justify-center gap-2 text-sm text-subtle">
<time datetime={post.data.pubDate.toISOString()}>{date}</time>
{!compact && taxonomy.categories.length > 0 && <span aria-hidden="true">·</span>}
{!compact && taxonomy.categories.map((category) => <a data-text-link href={`/categories/${slugify(category)}/`}>{category}</a>)}
{!compact && showTags && taxonomy.tags.length > 0 && <span class="inline-flex flex-wrap gap-2" aria-label="标签">{taxonomy.tags.map((tag) => <a class="no-underline" href={`/tags/${slugify(tag)}/`}>#{tag}</a>)}</span>}
</div>
+90
View File
@@ -0,0 +1,90 @@
---
import { Search as SearchIcon } from '@lucide/astro';
import { siteConfig } from '@/site.config';
interface Props { mobile?: boolean; }
const { mobile = false } = Astro.props;
const placeholder = siteConfig.search.provider === 'pagefind' ? siteConfig.search.placeholder : '搜索';
---
<div class:list={['relative', { 'w-full': mobile, 'navbar-search-container': !mobile }]} data-search-root data-mobile-search={mobile ? '' : undefined}>
{!mobile && <button class="grid size-[2.5rem] cursor-pointer place-items-center border-0 bg-transparent text-current hover:bg-current/10" type="button" aria-label="展开搜索" title="搜索" aria-expanded="false" data-search-toggle>
<SearchIcon aria-hidden="true" size="1em" />
</button>}
<form class:list={['search-form items-center border-b text-current', mobile ? 'flex w-full border-line px-1' : 'absolute top-0 right-[2.5rem] flex h-[2.5rem] w-[18rem] border-current/50 bg-nav px-2 opacity-0 shadow-sm pointer-events-none']} action={`${import.meta.env.BASE_URL}search/`} method="get" role="search" data-search-form>
<input class="min-w-0 flex-1 border-0 bg-transparent px-2 py-2 text-sm text-current outline-none ring-0 shadow-none focus:border-0 focus:outline-none focus:ring-0 focus:shadow-none placeholder:text-current/65" type="search" name="q" required placeholder={placeholder} autocomplete="off" spellcheck="false" aria-label="搜索文章" data-search-input />
{mobile && <button class="grid size-[2rem] shrink-0 cursor-pointer place-items-center border-0 bg-transparent text-current hover:bg-current/10" type="submit" aria-label="搜索" title="搜索"><SearchIcon aria-hidden="true" size="1em" /></button>}
</form>
</div>
<script>
const closeSearch = (root: HTMLElement, restoreFocus = false) => {
const input = root.querySelector<HTMLInputElement>('[data-search-input]');
const toggle = root.querySelector<HTMLButtonElement>('[data-search-toggle]');
if (root.hasAttribute('data-mobile-search')) input?.blur();
else {
root.classList.remove('is-open');
toggle?.setAttribute('aria-expanded', 'false');
if (restoreFocus) toggle?.focus();
}
};
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const toggle = event.target.closest<HTMLButtonElement>('[data-search-toggle]');
if (!toggle) return;
const root = toggle.closest<HTMLElement>('[data-search-root]');
if (!root) return;
if (root.classList.contains('is-open')) closeSearch(root, true);
else {
root.classList.add('is-open');
toggle.setAttribute('aria-expanded', 'true');
requestAnimationFrame(() => root.querySelector<HTMLInputElement>('[data-search-input]')?.focus());
}
});
document.addEventListener('submit', (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !form.matches('[data-search-form]')) return;
if (!form.querySelector<HTMLInputElement>('[data-search-input]')?.value.trim()) event.preventDefault();
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !(event.target instanceof Element)) return;
const root = event.target.closest<HTMLElement>('[data-search-root]');
if (!root) return;
event.preventDefault();
closeSearch(root, true);
});
document.addEventListener('pointerdown', (event) => {
document.querySelectorAll<HTMLElement>('[data-search-root].is-open').forEach((root) => {
if (!root.contains(event.target as Node)) closeSearch(root);
});
});
</script>
<style is:global>
.navbar-search-container > .search-form {
transform-origin: right center;
scale: 0 1;
transition: scale 200ms ease-out, opacity 200ms ease-out;
}
.search-form input[type='search']::-webkit-search-cancel-button {
width: 1em;
height: 1em;
margin-inline: 0.25em;
}
.navbar-search-container.is-open > .search-form {
pointer-events: auto;
scale: 1 1;
opacity: 1;
}
/* The search field deliberately keeps focus semantics without a visible focus treatment. */
.search-form [data-search-input]:is(:focus, :focus-visible) {
border-color: transparent !important;
outline: none !important;
outline-offset: 0 !important;
box-shadow: none !important;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
---
import { siteConfig } from '@/site.config';
interface Props {
title?: string;
description?: string;
image?: string;
type?: 'website' | 'article';
noindex?: boolean;
}
const {
title,
description = siteConfig.site.description,
image,
type = 'website',
noindex = false
} = Astro.props;
const pageTitle = title ? `${title} - ${siteConfig.site.title}` : siteConfig.site.title;
const canonical = new URL(Astro.url.pathname, siteConfig.site.url);
const imageUrl = image ? new URL(image, siteConfig.site.url) : undefined;
---
<title>{pageTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content={type} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:site_name" content={siteConfig.site.title} />
{imageUrl && <meta property="og:image" content={imageUrl} />}
<meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
+227
View File
@@ -0,0 +1,227 @@
---
import type { MarkdownHeading } from 'astro';
import { ListTree, X } from '@lucide/astro';
interface Props {
headings: MarkdownHeading[];
position?: 'left' | 'right';
}
const { headings } = Astro.props;
const visibleHeadings = headings.filter((heading) => heading.depth >= 2 && heading.depth <= 4);
---
{visibleHeadings.length > 0 && (
<div class="toc" data-toc-root data-pagefind-ignore="all">
<button class="toc-toggle" type="button" aria-label="打开文章目录" aria-controls="article-toc" aria-expanded="false" aria-hidden="false" data-toc-toggle>
<ListTree class="toc-open-icon" aria-hidden="true" size={18} />
<X class="toc-close-icon" aria-hidden="true" size={18} />
</button>
<div class="toc-backdrop" aria-hidden="true" data-toc-backdrop></div>
<aside class="toc-drawer" id="article-toc" aria-label="文章目录" aria-hidden="true" data-toc-drawer>
<header class="toc-header">
<h2>文章目录</h2>
</header>
<nav>
<ul>
{visibleHeadings.map((heading) => (
<li class={`depth-${heading.depth}`}><a href={`#${heading.slug}`}>{heading.text}</a></li>
))}
</ul>
</nav>
</aside>
</div>
)}
<style>
.toc {
--toc-drawer-width: min(280px, calc(100vw - 18px));
}
.toc-toggle {
position: fixed;
z-index: 43;
top: 50%;
right: 0;
display: grid;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--color-border);
border-right: 0;
border-radius: 4px 0 0 4px;
background: var(--color-surface);
color: var(--color-muted);
box-shadow: none;
cursor: pointer;
transform: translateY(-50%);
transition: right 220ms ease, color var(--transition-fast), background var(--transition-fast);
place-items: center;
}
.toc-close-icon { display: none; }
.toc-toggle:hover,
.toc-toggle[aria-expanded='true'] {
color: var(--color-accent-strong);
}
.toc-backdrop {
position: fixed;
z-index: 41;
inset: 0;
background: rgb(0 0 0 / 28%);
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast);
}
.toc-drawer {
position: fixed;
z-index: 42;
top: 0;
right: 0;
bottom: 0;
width: var(--toc-drawer-width);
padding: max(18px, env(safe-area-inset-top)) 22px max(18px, env(safe-area-inset-bottom));
border-left: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
box-shadow: none;
overflow-y: auto;
transform: translateX(100%);
transition: transform 220ms ease;
}
.toc.is-open .toc-backdrop {
opacity: 1;
pointer-events: auto;
}
.toc.is-open .toc-drawer {
transform: translateX(0);
}
.toc.is-open .toc-toggle {
right: var(--toc-drawer-width);
}
.toc.is-open .toc-open-icon { display: none; }
.toc.is-open .toc-close-icon { display: block; }
.toc-header {
display: flex;
min-height: 42px;
align-items: center;
margin-bottom: 14px;
border-bottom: 1px solid var(--color-border);
padding-bottom: 10px;
}
.toc-header h2 {
margin: 0;
font-family: var(--reading-font-family);
font-size: .9rem;
font-weight: 700;
}
.toc ul {
margin: 0;
padding: 0;
list-style: none;
}
.toc li {
margin: 4px 0;
font-family: var(--reading-font-family);
font-size: .86rem;
line-height: 1.45;
}
.toc li a {
display: block;
border-left: 2px solid transparent;
padding: 7px 8px;
color: var(--color-muted);
text-decoration: none;
}
.toc li a:hover,
.toc li a[aria-current] {
border-left-color: var(--color-accent);
color: var(--color-accent-strong);
}
.toc li a[aria-current] {
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
font-weight: 700;
}
.toc .depth-3 { padding-left: 12px; }
.toc .depth-4 { padding-left: 24px; font-size: .8rem; }
</style>
<script>
const setOpen = (open: boolean, restoreFocus = false) => {
const root = document.querySelector<HTMLElement>('[data-toc-root]');
const toggle = root?.querySelector<HTMLButtonElement>('[data-toc-toggle]');
const drawer = root?.querySelector<HTMLElement>('[data-toc-drawer]');
const backdrop = root?.querySelector<HTMLElement>('[data-toc-backdrop]');
root?.classList.toggle('is-open', open);
toggle?.setAttribute('aria-expanded', String(open));
toggle?.setAttribute('aria-label', open ? '关闭文章目录' : '打开文章目录');
drawer?.setAttribute('aria-hidden', String(!open));
backdrop?.setAttribute('aria-hidden', String(!open));
document.body.classList.toggle('toc-drawer-open', open);
if (restoreFocus) toggle?.focus();
};
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
const toggle = target.closest<HTMLButtonElement>('[data-toc-toggle]');
const backdrop = target.closest<HTMLElement>('[data-toc-backdrop]');
const link = target.closest<HTMLAnchorElement>('[data-toc-drawer] a[href^="#"]');
if (toggle) {
setOpen(toggle.getAttribute('aria-expanded') !== 'true', true);
} else if (backdrop) {
setOpen(false, true);
} else if (link) {
setOpen(false);
}
});
document.addEventListener('keydown', (event) => {
const toggle = document.querySelector<HTMLButtonElement>('[data-toc-toggle]');
if (event.key === 'Escape' && toggle?.getAttribute('aria-expanded') === 'true') setOpen(false, true);
});
const updateCurrent = () => {
const root = document.querySelector<HTMLElement>('[data-toc-root]');
const links = [...(root?.querySelectorAll<HTMLAnchorElement>('a[href^="#"]') ?? [])];
const headingLinks = links.map((link) => ({ link, heading: document.getElementById(decodeURIComponent(link.hash.slice(1))) })).filter((item): item is { link: HTMLAnchorElement; heading: HTMLElement } => Boolean(item.heading));
if (!headingLinks.length) return;
const current = headingLinks.reduce((active, item) => item.heading.getBoundingClientRect().top <= 112 ? item : active, headingLinks[0]);
headingLinks.forEach(({ link }) => {
if (link === current.link) link.setAttribute('aria-current', 'location');
else link.removeAttribute('aria-current');
});
};
{
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
updateCurrent();
ticking = false;
});
}
}, { passive: true });
updateCurrent();
document.addEventListener('astro:page-load', updateCurrent);
document.addEventListener('astro:before-preparation', () => setOpen(false));
}
</script>
+165
View File
@@ -0,0 +1,165 @@
---
import { CloudSun, Monitor, Moon, Minus, Plus, Sun, Type } from '@lucide/astro';
interface Props { placement?: 'down' | 'up'; }
const { placement = 'down' } = Astro.props;
const themes = [
{ value: 'auto', label: '自动', icon: Monitor },
{ value: 'light', label: '日间', icon: Sun },
{ value: 'sunset', label: '日落', icon: CloudSun },
{ value: 'dark', label: '夜间', icon: Moon }
] as const;
---
<details class:list={[placement === 'down' ? 'relative' : 'static', { 'placement-up': placement === 'up' }]} data-reading-settings>
<summary class="grid size-[2.5rem] cursor-pointer list-none place-items-center text-current/80 hover:bg-current/10 hover:text-current [&::-webkit-details-marker]:hidden" aria-label="打开阅读设置" aria-expanded="false" title="阅读设置">
<Type aria-hidden="true" size="1em" />
</summary>
<div class:list={['reading-settings-panel absolute z-[80] border border-line bg-raised p-[0.75rem] shadow-mirages', placement === 'up' ? 'bottom-[calc(100%+0.5rem)] left-0 w-[min(17.5rem,84vw)] max-w-[calc(100vw-1.75rem)] max-h-[calc(100svh-1.75rem)] overflow-y-auto' : 'top-[calc(100%+0.5rem)] right-0 w-[min(17.875rem,calc(100vw-1.75rem))]']} role="dialog" aria-label="阅读设置">
<div class="m-0">
<div class="mb-[0.25rem] flex items-center gap-[0.5rem]">
<h3 class="m-0 shrink-0 text-xs font-normal leading-5 text-subtle">颜色主题</h3>
<span class="h-px min-w-0 flex-1 bg-line" aria-hidden="true"></span>
</div>
<div class="grid grid-cols-4 gap-1" role="radiogroup" aria-label="颜色主题">
{themes.map(({ value, label, icon: Icon }) => (
<button class="grid min-h-[3.5rem] cursor-pointer place-items-center gap-[0.125rem] border border-transparent bg-transparent px-[0.25rem] py-[0.25rem] text-xs text-foreground hover:border-line hover:bg-mirages-accent/10" type="button" role="radio" aria-checked="false" data-theme-value={value}>
<Icon aria-hidden="true" size="1.0625rem" />
<span>{label}</span>
</button>
))}
</div>
</div>
<div class="mt-[0.375rem]">
<div class="mb-[0.25rem] flex items-center gap-[0.5rem]">
<h3 class="m-0 shrink-0 text-xs font-normal leading-5 text-subtle">字号</h3>
<span class="h-px min-w-0 flex-1 bg-line" aria-hidden="true"></span>
</div>
<div class="flex items-center gap-[0.5rem]">
<button class="grid h-[2rem] min-w-0 flex-[2] cursor-pointer place-items-center border border-line bg-transparent text-foreground hover:bg-mirages-accent/10" type="button" aria-label="减小字号" data-scale-step="-5"><Minus aria-hidden="true" size="1em" /></button>
<output class="min-w-0 flex-1 text-center text-xs font-semibold" data-scale-output>100%</output>
<button class="grid h-[2rem] min-w-0 flex-[2] cursor-pointer place-items-center border border-line bg-transparent text-foreground hover:bg-mirages-accent/10" type="button" aria-label="增大字号" data-scale-step="5"><Plus aria-hidden="true" size="1em" /></button>
</div>
</div>
<div class="mt-[0.375rem]">
<div class="mb-[0.25rem] flex items-center gap-[0.5rem]">
<h3 class="m-0 shrink-0 text-xs font-normal leading-5 text-subtle">字体</h3>
<span class="h-px min-w-0 flex-1 bg-line" aria-hidden="true"></span>
</div>
<div class="grid grid-cols-2 gap-1" role="radiogroup" aria-label="字体">
<button class="cursor-pointer border border-line bg-transparent px-[0.5rem] py-[0.375rem] text-xs text-foreground hover:bg-mirages-accent/10 [font-family:var(--mirages-font-serif)]" type="button" role="radio" aria-checked="false" data-font-value="serif">宋体</button>
<button class="cursor-pointer border border-line bg-transparent px-[0.5rem] py-[0.375rem] text-xs text-foreground hover:bg-mirages-accent/10 [font-family:var(--mirages-font-sans)]" type="button" role="radio" aria-checked="false" data-font-value="sans">黑体</button>
</div>
</div>
</div>
</details>
<script>
const root = document.documentElement;
const getStoredFont = () => {
const font = localStorage.getItem('mirages-font-family');
return font === 'serif' || font === 'sans' ? font : 'sans';
};
const getStoredTheme = () => {
const theme = localStorage.getItem('mirages-theme');
return theme === 'auto' || theme === 'light' || theme === 'sunset' || theme === 'dark' ? theme : null;
};
const syncSwitchers = (theme: string) => {
const switchers = document.querySelectorAll<HTMLElement>('[data-reading-settings]');
switchers.forEach((switcher) => {
switcher.querySelectorAll<HTMLButtonElement>('[data-theme-value]').forEach((button) => {
button.setAttribute('aria-checked', String(button.dataset.themeValue === theme));
});
switcher.querySelectorAll<HTMLButtonElement>('[data-font-value]').forEach((button) => {
button.setAttribute('aria-checked', String(button.dataset.fontValue === (root.dataset.font ?? 'sans')));
});
});
};
const getScale = () => {
const storedScale = Number(localStorage.getItem('mirages-font-scale'));
return Math.min(150, Math.max(80, Math.round((storedScale >= 0.8 && storedScale <= 1.5 ? storedScale * 100 : storedScale >= 80 && storedScale <= 150 ? storedScale : 100) / 5) * 5));
};
const syncScale = (value: number, persist = true) => {
const scale = Math.min(150, Math.max(80, Math.round(value / 5) * 5));
root.style.fontSize = `${scale}%`;
document.querySelectorAll<HTMLOutputElement>('[data-scale-output]').forEach((output) => {
output.value = `${scale}%`;
output.textContent = `${scale}%`;
});
if (persist) localStorage.setItem('mirages-font-scale', String(scale / 100));
};
root.dataset.font = getStoredFont();
syncSwitchers(root.dataset.theme ?? 'auto');
syncScale(getScale(), false);
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
const switcher = target.closest<HTMLElement>('[data-reading-settings]');
if (!switcher) return;
const theme = target.closest<HTMLButtonElement>('[data-theme-value]')?.dataset.themeValue;
if (theme) {
root.dataset.theme = theme;
localStorage.setItem('mirages-theme', theme);
syncSwitchers(theme);
return;
}
const step = target.closest<HTMLButtonElement>('[data-scale-step]')?.dataset.scaleStep;
if (step) {
syncScale((Number.parseFloat(root.style.fontSize) || getScale()) + Number(step));
return;
}
const font = target.closest<HTMLButtonElement>('[data-font-value]')?.dataset.fontValue;
if (font) {
root.dataset.font = font;
localStorage.setItem('mirages-font-family', font);
syncSwitchers(root.dataset.theme ?? 'auto');
}
});
document.addEventListener('toggle', (event) => {
const switcher = event.target as HTMLElement;
if (!switcher.matches('[data-reading-settings]')) return;
const trigger = switcher.querySelector<HTMLElement>('summary');
const open = switcher.hasAttribute('open');
trigger?.setAttribute('aria-expanded', String(open));
if (open) {
document.querySelectorAll<HTMLElement>('[data-reading-settings]').forEach((other) => { if (other !== switcher) other.removeAttribute('open'); });
switcher.querySelector<HTMLButtonElement>('[data-theme-value]')?.focus();
}
}, true);
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !(event.target instanceof Element)) return;
const switcher = event.target.closest<HTMLElement>('[data-reading-settings]');
if (!switcher) return;
event.preventDefault();
event.stopPropagation();
switcher.removeAttribute('open');
switcher.querySelector<HTMLElement>('summary')?.focus();
});
document.addEventListener('pointerdown', (event) => {
document.querySelectorAll<HTMLElement>('[data-reading-settings]').forEach((switcher) => {
if (!switcher.contains(event.target as Node)) switcher.removeAttribute('open');
});
});
document.addEventListener('astro:before-swap', (event) => {
const theme = getStoredTheme();
if (theme) event.newDocument.documentElement.dataset.theme = theme;
});
document.addEventListener('astro:page-load', () => {
root.dataset.theme = getStoredTheme() ?? root.dataset.theme ?? 'auto';
root.dataset.font = getStoredFont();
syncSwitchers(root.dataset.theme ?? 'auto');
syncScale(getScale(), false);
});
</script>
<style>
button[aria-checked='true'] { border-color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
[data-reading-settings] > summary { border-radius: var(--radius-sm); }
.reading-settings-panel,
.reading-settings-panel button { font-family: var(--reading-font-family); }
.reading-settings-panel button[data-font-value='serif'] { font-family: var(--mirages-font-serif) !important; }
.reading-settings-panel button[data-font-value='sans'] { font-family: var(--mirages-font-sans) !important; }
</style>
+11
View File
@@ -0,0 +1,11 @@
---
import { Link, Rss, Search, TramFront, Type } from '@lucide/astro';
import type { ToolbarIcon as ToolbarIconName } from '@/types/config';
interface Props { icon: ToolbarIconName; }
const { icon } = Astro.props;
const icons = { link: Link, rss: Rss, search: Search, settings: Type, 'tram-front': TramFront } as const;
const Icon = icons[icon];
---
<Icon aria-hidden="true" size="1em" />
+69
View File
@@ -0,0 +1,69 @@
<script is:inline>
(() => {
const state = window.__miragesTwikoo ??= {};
if (state.clientBound) return;
state.clientBound = true;
const loadScript = () => {
if (state.script) return state.script;
state.script = new Promise((resolve, reject) => {
if (window.twikoo) return resolve(window.twikoo);
const sources = [
'https://registry.npmmirror.com/twikoo/1.7.15/files/dist/twikoo.nocss.js',
'https://cdn.jsdelivr.net/npm/twikoo@1.7.15/dist/twikoo.nocss.js'
];
const loadSource = (index) => {
if (index >= sources.length) return reject(new Error('Twikoo 加载失败'));
const script = document.createElement('script');
const timer = window.setTimeout(() => { script.remove(); loadSource(index + 1); }, 8000);
script.src = sources[index];
script.async = true;
script.onload = () => { window.clearTimeout(timer); window.twikoo ? resolve(window.twikoo) : loadSource(index + 1); };
script.onerror = () => { window.clearTimeout(timer); loadSource(index + 1); };
document.head.appendChild(script);
};
loadSource(0);
});
return state.script;
};
const waitForStyles = () => {
const link = document.querySelector('link[data-twikoo-styles]');
if (!(link instanceof HTMLLinkElement) || link.sheet) return Promise.resolve();
return new Promise((resolve) => {
link.addEventListener('load', () => resolve(), { once: true });
link.addEventListener('error', () => resolve(), { once: true });
});
};
const initCurrentComments = () => {
const root = document.querySelector('#twikoo-comments');
if (!(root instanceof HTMLElement) || root.dataset.twikooBound) return;
let config;
try {
config = JSON.parse(root.dataset.twikooConfig ?? '{}');
} catch {
return;
}
root.dataset.twikooBound = 'true';
root.dataset.twikooState = 'loading';
Promise.all([loadScript(), waitForStyles()]).then(([twikoo]) => {
if (!root.isConnected || document.querySelector('#twikoo-comments') !== root) return;
return twikoo.init({ ...config, el: '#twikoo-comments' });
}).then(() => {
if (root.isConnected) root.dataset.twikooState = 'ready';
}).catch(() => {
if (root.isConnected) {
root.dataset.twikooState = 'error';
root.setAttribute('data-twikoo-error', 'true');
}
});
};
initCurrentComments();
document.addEventListener('astro:page-load', initCurrentComments);
document.addEventListener('astro:before-swap', () => {
document.querySelector('#twikoo-comments')?.replaceChildren();
});
})();
</script>
+37
View File
@@ -0,0 +1,37 @@
---
import { siteConfig } from '@/site.config';
const enabled = siteConfig.comments.provider === 'twikoo' && Boolean(siteConfig.comments.envId.trim());
const clientConfig = siteConfig.comments.provider === 'twikoo' && siteConfig.comments.envId.trim() ? {
envId: siteConfig.comments.envId,
...(siteConfig.comments.envId.startsWith('http') ? {} : { region: siteConfig.comments.region })
} : null;
---
{enabled && clientConfig && <script is:inline define:vars={{ config: clientConfig }}>
(() => {
const nodes = [...document.querySelectorAll('[data-twikoo-url]')];
if (!nodes.length) return;
const start = () => {
const urls = [...new Set(nodes.map((node) => node.dataset.twikooUrl).filter(Boolean))];
const state = window.__miragesTwikoo ??= {};
const load = state.script ??= new Promise((resolve, reject) => {
if (window.twikoo) return resolve(window.twikoo);
const script = document.createElement('script');
script.src = 'https://registry.npmmirror.com/twikoo/1.7.15/files/dist/twikoo.nocss.js';
script.async = true;
script.onload = () => window.twikoo ? resolve(window.twikoo) : reject(new Error('Twikoo 不可用'));
script.onerror = () => reject(new Error('Twikoo 加载失败'));
document.head.appendChild(script);
});
load.then((twikoo) => twikoo.getCommentsCount({ envId: config.envId, region: config.region, urls, includeReply: false }))
.then((counts) => (counts ?? []).forEach((item) => {
const target = nodes.find((node) => node.dataset.twikooUrl === item.url)?.querySelector('[data-twikoo-count]');
if (typeof item.count === 'number' && target) {
target.textContent = `${item.count} 条评论`;
target.hidden = false;
}
})).catch(() => undefined);
};
const idle = window.requestIdleCallback ?? ((callback) => window.setTimeout(callback, 1));
idle(start);
})();
</script>}
+173
View File
@@ -0,0 +1,173 @@
---
import { siteConfig } from '@/site.config';
interface Props { enabled: boolean; path?: string; }
const { enabled, path } = Astro.props;
const comments = siteConfig.comments;
const config = comments.provider === 'twikoo' && comments.envId.trim() ? {
envId: comments.envId,
lang: comments.lang ?? siteConfig.site.locale,
path: comments.path ?? path ?? Astro.url.pathname,
...(comments.envId.startsWith('http') ? {} : { region: comments.region })
} : null;
---
{enabled && config && <section class="comments-shell" id="comments" aria-labelledby="comments-title">
<h2 id="comments-title">评论</h2>
<div id="twikoo-comments" data-twikoo-config={JSON.stringify(config)} data-twikoo-state="idle"></div>
</section>}
{enabled && !config && <section class="comments-shell comments-preview" id="comments" aria-labelledby="comments-title" data-pagefind-ignore="all">
<h2 id="comments-title">评论</h2>
<div class="twikoo-preview" aria-label="Twikoo 评论预览">
<p class="twikoo-preview-status">Twikoo 尚未配置</p>
<div class="twikoo-preview-fields" aria-hidden="true">
<span>昵称</span><span>邮箱</span><span>网址</span>
</div>
<div class="twikoo-preview-editor" aria-hidden="true">留下你的评论...</div>
<div class="twikoo-preview-actions" aria-hidden="true"><span>表情</span><button type="button" disabled>发布评论</button></div>
<p class="twikoo-preview-empty">暂无评论</p>
</div>
</section>}
<style is:global>
#comments { width: min(100%, var(--article-content-width)); margin: 4.375rem auto 0; }
#comments > h2 { margin: 0 0 1.5rem; padding-top: 1.375rem; border-top: 1px solid var(--color-border); color: var(--color-accent-strong); font-family: var(--reading-font-family); font-size: 1.5625rem; font-weight: 500; }
#twikoo-comments, #comments > #twikoo { color: var(--color-text); font-size: .94rem; }
#twikoo-comments .tk-comments-title, #comments > #twikoo .tk-comments-title { margin: 0 0 18px; color: var(--color-text); font-family: var(--font-serif); font-size: 1.1rem; font-weight: 600; }
#twikoo-comments .tk-comment, #comments > #twikoo .tk-comment { padding: 1.375rem 0; border-bottom: 1px solid var(--color-border); }
#twikoo-comments .tk-content, #twikoo-comments .tk-comment-content, #comments > #twikoo .tk-content, #comments > #twikoo .tk-comment-content { margin-top: .5rem; color: var(--color-text); line-height: 1.75; overflow-wrap: anywhere; }
#twikoo-comments .tk-content p, #twikoo-comments .tk-comment-content p, #comments > #twikoo .tk-content p, #comments > #twikoo .tk-comment-content p { margin: .35em 0; }
#twikoo-comments .tk-avatar, #twikoo-comments .tk-comment-avatar, #comments > #twikoo .tk-avatar, #comments > #twikoo .tk-comment-avatar { width: 3.125rem; height: 3.125rem; border-radius: 50%; background: var(--color-code); }
#twikoo-comments .tk-comment-name, #twikoo-comments .tk-nick, #comments > #twikoo .tk-comment-name, #comments > #twikoo .tk-nick { color: var(--color-text); font-weight: 700; }
#twikoo-comments .tk-meta, #twikoo-comments .tk-time, #twikoo-comments .tk-comment-actions, #twikoo-comments .tk-comment-edit, #comments > #twikoo .tk-meta, #comments > #twikoo .tk-time, #comments > #twikoo .tk-comment-actions, #comments > #twikoo .tk-comment-edit { color: var(--color-muted); font-size: .78rem; }
#twikoo-comments .tk-replies, #comments > #twikoo .tk-replies { margin: 1rem 0 0 -.25rem; padding-left: .375rem; border-left: 2px solid var(--color-border); }
#twikoo-comments .tk-replies .tk-comment, #comments > #twikoo .tk-replies .tk-comment { padding-block: .875rem; }
#twikoo-comments .tk-main, #comments > #twikoo .tk-main { margin-top: 1.375rem; }
#twikoo-comments .tk-input, #comments > #twikoo .tk-input { width: 100%; }
#twikoo-comments textarea, #twikoo-comments input, #comments > #twikoo textarea, #comments > #twikoo input { width: 100%; border: 2px solid var(--color-accent); border-radius: var(--radius-sm); background: var(--color-surface); color: var(--color-text); }
#twikoo-comments textarea, #comments > #twikoo textarea { min-height: 120px; padding: 13px 15px; resize: vertical; }
#twikoo-comments input, #comments > #twikoo input { padding: 9px 11px; }
#twikoo-comments textarea:focus, #twikoo-comments input:focus, #comments > #twikoo textarea:focus, #comments > #twikoo input:focus { border-color: var(--color-accent-strong); outline: none; }
#twikoo-comments button, #twikoo-comments .tk-submit, #comments > #twikoo button, #comments > #twikoo .tk-submit { border: 1px solid var(--color-accent-strong); border-radius: var(--radius-sm); background: var(--color-accent); color: var(--color-accent-contrast); cursor: pointer; font-weight: 700; }
#twikoo-comments .tk-owo, #twikoo-comments .OwO, #comments > #twikoo .tk-owo, #comments > #twikoo .OwO { color: var(--color-muted); }
#twikoo-comments .OwO .OwO-body, #comments > #twikoo .OwO .OwO-body { border-color: var(--color-border); background: var(--color-surface); }
#twikoo-comments .tk-loading, #twikoo-comments .tk-empty, #twikoo-comments .tk-error, #comments > #twikoo .tk-loading, #comments > #twikoo .tk-empty, #comments > #twikoo .tk-error { padding: 22px 0; color: var(--color-muted); }
#twikoo-comments .tk-loading, #comments > #twikoo .tk-loading, #twikoo-comments .el-loading-spinner, #comments > #twikoo .el-loading-spinner { color: var(--color-accent) !important; }
#twikoo-comments .tk-loading :is(svg, path), #comments > #twikoo .tk-loading :is(svg, path), #twikoo-comments .el-loading-spinner :is(svg, path), #comments > #twikoo .el-loading-spinner :is(svg, path) { color: var(--color-accent) !important; stroke: currentColor !important; }
#twikoo-comments .el-loading-spinner .path, #comments > #twikoo .el-loading-spinner .path { stroke: var(--color-accent) !important; }
#twikoo-comments .tk-loading::before, #comments > #twikoo .tk-loading::before { border-color: color-mix(in srgb, var(--color-accent) 25%, transparent) !important; border-top-color: var(--color-accent) !important; }
#twikoo-comments[data-twikoo-state='idle'] { min-height: 80px; }
#twikoo-comments[data-twikoo-state='idle']::before { content: '评论将在滚动到此处时加载'; display: block; color: var(--color-muted); font-size: .86rem; }
#twikoo-comments[data-twikoo-error='true']::before { content: '评论暂时不可用。'; display: block; padding: 22px 0; color: var(--color-muted); }
.twikoo-preview { border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text); }
.twikoo-preview-status { margin: 0; padding: 12px 15px; border-bottom: 1px solid var(--color-border); color: var(--color-muted); font-size: .86rem; }
.twikoo-preview-fields { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--color-border); }
.twikoo-preview-fields span { padding: 10px 12px; color: var(--color-muted); font-size: .82rem; }
.twikoo-preview-fields span + span { border-left: 1px solid var(--color-border); }
.twikoo-preview-editor { min-height: 120px; padding: 14px 15px; color: var(--color-muted); }
.twikoo-preview-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--color-border); color: var(--color-muted); font-size: .85rem; }
.twikoo-preview-actions button { padding: 7px 12px; border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-raised); color: var(--color-muted); cursor: not-allowed; font-weight: 700; }
.twikoo-preview-empty { margin: 0; padding: 18px 15px; border-top: 1px solid var(--color-border); color: var(--color-muted); font-size: .9rem; text-align: center; }
#comments > #twikoo { margin: 0; background: transparent !important; color: var(--color-text) !important; font-family: var(--reading-font-family) !important; }
#comments > #twikoo a { color: var(--color-accent) !important; }
#comments > #twikoo .tk-submit { padding: 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; }
#comments > #twikoo .tk-row { align-items: flex-start !important; gap: .625rem !important; }
#comments > #twikoo .tk-submit > .tk-row { display: block !important; position: relative !important; }
#comments > #twikoo .tk-submit > .tk-row > .tk-avatar { position: absolute !important; top: 0 !important; left: 0 !important; }
#comments > #twikoo .tk-avatar { flex: 0 0 3.125rem !important; width: 3.125rem !important; height: 3.125rem !important; margin: 0 .625rem 0 0 !important; border-radius: 50% !important; background: var(--color-code) !important; color: var(--color-muted) !important; }
#comments > #twikoo .tk-avatar-img { width: 100% !important; height: 100% !important; }
#comments > #twikoo .tk-avatar-img svg { display: block !important; width: calc(100% + 2px) !important; height: calc(100% + 2px) !important; margin: -1px !important; padding: 0 !important; shape-rendering: geometricPrecision; }
#comments > #twikoo .tk-submit > .tk-row > .tk-col { box-sizing: border-box !important; width: 100% !important; min-width: 0 !important; padding-left: 3.75rem !important; }
#comments > #twikoo .tk-meta-input { box-sizing: border-box !important; display: grid !important; grid-template-columns: repeat(3, minmax(0, 1fr)) !important; gap: .625rem !important; width: 100% !important; margin-bottom: .625rem !important; }
#comments > #twikoo .tk-meta-input > .el-input { width: 100% !important; min-width: 0 !important; height: 2.125rem !important; }
#comments > #twikoo .tk-meta-input > .el-input.el-input-group { display: flex !important; box-sizing: border-box !important; margin-left: 0 !important; }
#comments > #twikoo .tk-meta-input > .el-input.el-input-group .el-input-group__prepend { box-sizing: border-box !important; display: flex !important; flex: 0 0 3.75rem !important; align-items: center !important; justify-content: center !important; min-width: 3.75rem !important; padding-inline: .35rem !important; text-align: center !important; white-space: nowrap !important; }
#comments > #twikoo .tk-meta-input > .el-input.el-input-group .el-input__inner { min-width: 0 !important; flex: 1 1 auto !important; }
#comments > #twikoo .el-input-group__prepend { padding: 0 .55rem !important; border: 1px solid var(--color-border) !important; border-right: 0 !important; border-radius: var(--radius-sm) 0 0 var(--radius-sm) !important; background: var(--color-code) !important; color: var(--color-muted) !important; font-size: .78rem !important; }
#comments > #twikoo .el-input__inner { height: 2.125rem !important; padding: .438rem .525rem !important; border: 1px solid var(--color-border) !important; border-radius: 0 var(--radius-sm) var(--radius-sm) 0 !important; background: var(--color-surface) !important; color: var(--color-text) !important; font-family: var(--reading-font-family) !important; box-shadow: none !important; }
#comments > #twikoo .el-input__inner:focus, #comments > #twikoo .el-textarea__inner:focus { border-color: var(--color-accent) !important; }
#comments > #twikoo .tk-input { width: 100% !important; }
#comments > #twikoo .el-textarea__inner { min-height: 7.5rem !important; padding: .75rem .875rem !important; border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: var(--color-surface) !important; color: var(--color-text) !important; font-family: var(--reading-font-family) !important; line-height: 1.6 !important; box-shadow: none !important; resize: vertical !important; }
#comments > #twikoo .el-input__count { right: .625rem !important; bottom: .35rem !important; background: transparent !important; color: var(--color-muted) !important; }
#comments > #twikoo .tk-row.actions { box-sizing: border-box !important; display: flex !important; justify-content: flex-end !important; width: calc(100% - 3.75rem) !important; margin: .625rem 0 0 3.75rem !important; padding-top: .625rem !important; }
#comments > #twikoo .tk-row-actions-start { gap: .5rem !important; }
#comments > #twikoo .tk-submit-action-icon { color: var(--color-muted) !important; }
#comments > #twikoo .OwO-logo { color: var(--color-muted) !important; }
#comments > #twikoo .OwO-body { border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: var(--color-surface) !important; color: var(--color-text) !important; box-shadow: var(--shadow-soft) !important; }
#comments > #twikoo .tk-send { min-width: 7rem !important; height: 2.25rem !important; border: 0 !important; border-radius: var(--radius-sm) !important; background: var(--color-accent) !important; color: var(--color-accent-contrast) !important; font-weight: 500 !important; }
#comments > #twikoo .tk-send.is-disabled { opacity: .55 !important; }
#comments > #twikoo .tk-preview { border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: transparent !important; color: var(--color-muted) !important; }
#comments > #twikoo .tk-comments-container { margin-top: 3rem !important; }
#comments > #twikoo .tk-comments-title { display: flex !important; align-items: center !important; justify-content: space-between !important; gap: 1rem !important; }
#comments > #twikoo .tk-comments-actions { display: flex !important; align-items: center !important; gap: .5rem !important; margin-left: auto !important; }
#comments > #twikoo .tk-comments-actions .tk-sort-item { display: inline-flex !important; align-items: center !important; height: 1.75rem !important; padding: .125rem .35rem !important; border: 0 !important; border-radius: var(--radius-sm) !important; background: transparent !important; color: var(--color-muted) !important; font-family: var(--reading-font-family) !important; font-size: .75rem !important; font-weight: 400 !important; }
#comments > #twikoo .tk-comments-actions .tk-sort-item:hover, #comments > #twikoo .tk-comments-actions .tk-sort-item.__active { background: var(--color-code) !important; color: var(--color-accent) !important; }
#comments > #twikoo .tk-comments-actions .tk-icon:first-of-type { display: none !important; }
#comments > #twikoo .tk-comments-actions .tk-icon.__comments { color: var(--color-accent) !important; }
#comments > #twikoo .tk-comments-title { margin: 0 0 1rem !important; padding-top: 1.125rem !important; border-top: 1px solid var(--color-border) !important; color: var(--color-accent) !important; font-family: var(--reading-font-family) !important; font-size: 1.25rem !important; font-weight: 500 !important; }
#comments > #twikoo .tk-comment { align-items: flex-start !important; margin-top: 1.625rem !important; padding: 0 0 .5rem !important; border: 0 !important; background: transparent !important; }
#comments > #twikoo .tk-comment > .tk-avatar { flex: 0 0 2.25rem !important; width: 2.25rem !important; height: 2.25rem !important; margin: 0 .75rem 0 0 !important; }
#comments > #twikoo .tk-comment > .tk-main { min-width: 0 !important; margin-top: 0 !important; }
#comments > #twikoo .tk-comment > .tk-main > .tk-row { align-items: flex-start !important; }
#comments > #twikoo .tk-nick { color: var(--color-text) !important; font-size: .875rem !important; font-weight: 500 !important; }
#comments > #twikoo .tk-nick strong { color: inherit !important; font-weight: 500 !important; }
#comments > #twikoo .tk-time, #comments > #twikoo .tk-meta, #comments > #twikoo .tk-comment-actions { color: var(--color-muted) !important; font-size: .75rem !important; font-weight: 400 !important; }
#comments > #twikoo .tk-meta { align-items: baseline !important; gap: .5rem !important; }
#comments > #twikoo .tk-time { margin-left: .5rem !important; }
#comments > #twikoo .tk-action { display: flex !important; align-items: center !important; gap: .25rem !important; }
#comments > #twikoo .tk-action-link { display: inline-flex !important; align-items: center !important; gap: .2rem !important; min-width: 2.5rem !important; height: 1.75rem !important; padding: .125rem .3rem !important; border: 0 !important; border-radius: var(--radius-sm) !important; background: transparent !important; color: var(--color-muted) !important; font-family: var(--reading-font-family) !important; font-size: .75rem !important; font-weight: 400 !important; }
#comments > #twikoo .tk-action-link:hover { background: var(--color-code) !important; color: var(--color-accent) !important; }
#comments > #twikoo .tk-action-link:focus-visible { outline: 1px solid var(--color-accent) !important; outline-offset: 1px !important; }
#comments > #twikoo .tk-action-icon { display: inline-flex !important; width: .8rem !important; height: .8rem !important; color: currentColor !important; }
#comments > #twikoo .tk-action-icon svg { width: 100% !important; height: 100% !important; }
#comments > #twikoo .tk-action-icon-solid { display: none !important; }
#comments > #twikoo .tk-action-link:is(.tk-liked, .tk-disliked) .tk-action-icon { display: none !important; }
#comments > #twikoo .tk-action-link:is(.tk-liked, .tk-disliked) .tk-action-icon-solid { display: inline-flex !important; width: .8rem !important; height: .8rem !important; color: currentColor !important; }
#comments > #twikoo .tk-action-link:not(:is(.tk-liked, .tk-disliked)):hover .tk-action-icon { display: inline-flex !important; }
#comments > #twikoo .tk-action-link:not(:is(.tk-liked, .tk-disliked)):hover .tk-action-icon-solid { display: none !important; }
#comments > #twikoo .tk-action-link:nth-child(1)::after { content: '赞'; }
#comments > #twikoo .tk-action-link:nth-child(2)::after { content: '踩'; }
#comments > #twikoo .tk-action-link:nth-child(3)::after { content: '回复'; }
#comments > #twikoo .tk-action:has(> .tk-action-link:nth-child(4)) .tk-action-link:nth-child(1)::after { content: '删除'; }
#comments > #twikoo .tk-action:has(> .tk-action-link:nth-child(4)) .tk-action-link:nth-child(2)::after { content: '赞'; }
#comments > #twikoo .tk-action:has(> .tk-action-link:nth-child(4)) .tk-action-link:nth-child(3)::after { content: '踩'; }
#comments > #twikoo .tk-action:has(> .tk-action-link:nth-child(4)) .tk-action-link:nth-child(4)::after { content: '回复'; }
#comments > #twikoo .tk-action-count { color: inherit !important; font-size: .7rem !important; }
#comments > #twikoo .tk-action-link.tk-delete::after, #comments > #twikoo .tk-action-link[data-action='delete']::after { content: '删除'; }
#comments > #twikoo .tk-content, #comments > #twikoo .tk-comment-content { margin-top: .625rem !important; padding: 0 !important; color: var(--color-text) !important; font-size: .9375rem !important; line-height: 1.6 !important; }
#comments > #twikoo .tk-content p, #comments > #twikoo .tk-comment-content p { margin: .35em 0 !important; }
#comments > #twikoo .tk-replies { margin: 1rem 0 0 0 !important; padding-left: 1rem !important; border-left: .25rem solid var(--color-code) !important; }
#comments > #twikoo .tk-replies .tk-comment { padding-block: .875rem !important; }
#comments > #twikoo .tk-empty, #comments > #twikoo .tk-error { padding: 1rem 0 !important; color: var(--color-muted) !important; }
@media (max-width: 37.5rem) {
#comments { width: 100%; }
#comments > #twikoo { font-size: .9rem !important; }
#comments > #twikoo .tk-comments-title { align-items: flex-start !important; flex-wrap: wrap !important; }
#comments > #twikoo .tk-comments-title { gap: .625rem !important; }
#comments > #twikoo .tk-comments-actions { display: flex !important; flex-wrap: wrap !important; width: 100% !important; margin-left: 0 !important; gap: .25rem !important; }
#comments > #twikoo .tk-comments-actions .tk-sort-item { padding-inline: .25rem !important; }
#comments > #twikoo .tk-submit > .tk-row { display: flex !important; align-items: flex-start !important; gap: .5rem !important; }
#comments > #twikoo .tk-submit > .tk-row > .tk-avatar { position: static !important; flex: 0 0 1.875rem !important; width: 1.875rem !important; height: 1.875rem !important; margin: 0 !important; }
#comments > #twikoo .tk-submit > .tk-row > .tk-col { flex: 1 1 auto !important; width: auto !important; padding-left: 0 !important; }
#comments > #twikoo .tk-comment > .tk-avatar { flex: 0 0 2rem !important; width: 2rem !important; height: 2rem !important; margin: 0 .625rem 0 0 !important; }
#comments > #twikoo .tk-avatar { flex-shrink: 0 !important; }
#comments > #twikoo .tk-meta-input { grid-template-columns: 1fr !important; gap: .35rem !important; }
#comments > #twikoo .tk-meta-input > .el-input { min-width: 0 !important; }
#comments > #twikoo .tk-row.actions { display: flex !important; flex-wrap: wrap !important; align-items: center !important; justify-content: space-between !important; width: 100% !important; margin: .625rem 0 0 !important; padding-top: .5rem !important; gap: .5rem !important; }
#comments > #twikoo .tk-row-actions-start { flex: 1 1 auto !important; min-width: 0 !important; flex-wrap: wrap !important; }
#comments > #twikoo .tk-send { flex: 0 0 auto !important; min-width: 5.5rem !important; }
#comments > #twikoo .tk-meta { min-width: 0 !important; flex-wrap: wrap !important; gap: .25rem .5rem !important; }
#comments > #twikoo .tk-time { margin-left: 0 !important; }
#comments > #twikoo .tk-action { flex-wrap: wrap !important; gap: .125rem !important; }
#comments > #twikoo .tk-action-link { box-sizing: border-box !important; flex: 0 0 1.75rem !important; justify-content: center !important; width: 1.75rem !important; min-width: 1.75rem !important; height: 1.75rem !important; margin: 0 !important; padding: 0 !important; gap: 0 !important; }
#comments > #twikoo .tk-action-link .tk-action-count { display: none !important; margin: 0 !important; }
#comments > #twikoo .tk-action-link .tk-action-icon { flex: 0 0 .8rem !important; }
#comments > #twikoo .tk-action-link::after { content: none !important; }
#comments > #twikoo .tk-content, #comments > #twikoo .tk-comment-content { min-width: 0 !important; overflow-wrap: anywhere !important; }
#comments > #twikoo .tk-replies { width: 100% !important; margin-left: 0 !important; padding-left: .625rem !important; overflow: hidden !important; }
#comments > #twikoo .tk-replies .tk-comment { min-width: 0 !important; }
#comments > #twikoo .tk-replies .tk-comment > .tk-avatar { flex-basis: 1.75rem !important; width: 1.75rem !important; height: 1.75rem !important; margin-right: .5rem !important; }
#comments > #twikoo .OwO .OwO-body { right: 0 !important; left: auto !important; width: min(18rem, 100vw - 2rem) !important; }
}
@media (max-width: 37.5rem) { #comments { margin-top: 3.25rem; } #twikoo-comments .tk-avatar, #twikoo-comments .tk-comment-avatar { width: 1.875rem; height: 1.875rem; } }
</style>
+262
View File
@@ -0,0 +1,262 @@
---
import type { PostEntry } from '@/lib/content';
interface Props { posts: PostEntry[]; }
interface WeekCell { week: number; words: number; level: number; }
const { posts } = Astro.props;
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 3 }, (_, index) => currentYear - 2 + index);
function countWords(source: string): number {
const text = source
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/<[^>]+>/g, '')
.replace(/https?:\/\/\S+/g, '')
.replace(/[#{*_~>`|+-]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const chineseCharacters = text.match(/[\u3400-\u4dbf\u4e00-\u9fff]/g)?.length ?? 0;
const otherWords = text
.replace(/[\u3400-\u4dbf\u4e00-\u9fff]/g, ' ')
.split(/\s+/)
.filter(Boolean).length;
return chineseCharacters + otherWords;
}
function getIsoWeek(date: Date): { year: number; week: number } {
const utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const day = utcDate.getUTCDay() || 7;
utcDate.setUTCDate(utcDate.getUTCDate() + 4 - day);
const year = utcDate.getUTCFullYear();
const yearStart = new Date(Date.UTC(year, 0, 1));
const week = Math.ceil(((utcDate.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
return { year, week };
}
function getLevel(words: number): number {
if (words === 0) return 0;
if (words < 1_000) return 1;
if (words < 3_000) return 2;
if (words < 8_000) return 3;
return 4;
}
const wordsByWeek = new Map<string, number>();
let totalWords = 0;
for (const post of posts) {
const words = countWords(post.body ?? '');
totalWords += words;
const { year, week } = getIsoWeek(post.data.pubDate);
if (!years.includes(year)) continue;
const key = `${year}-${week}`;
wordsByWeek.set(key, (wordsByWeek.get(key) ?? 0) + words);
}
const yearRows = years.map((year) => ({
year,
weeks: Array.from({ length: 53 }, (_, index): WeekCell => {
const week = index + 1;
const words = wordsByWeek.get(`${year}-${week}`) ?? 0;
return { week, words, level: getLevel(words) };
})
}));
const totalWordsLabel = totalWords >= 10_000
? `${(totalWords / 10_000).toFixed(2)}W`
: totalWords.toLocaleString('zh-CN');
---
<section class="writing-heatmap" aria-labelledby="writing-heatmap-title" data-pagefind-ignore="all">
<header>
<div class="writing-heading">
<h2 id="writing-heatmap-title">写作热力图</h2>
<p class="writing-total">自博客建立以来,已输出 <span class="writing-total-value">{totalWordsLabel}</span><span class="writing-total-label">字</span></p>
</div>
<div class="heatmap-legend" aria-label="写作字数:从少到多">
<span>少</span>
{[0, 1, 2, 3, 4].map((level) => <i class={`level-${level}`} aria-hidden="true"></i>)}
<span>多</span>
</div>
</header>
<div class="heatmap-viewport" data-heatmap-viewport>
<div class="heatmap-scroll" aria-label="最近三年每周写作字数热力图" data-heatmap-scroll>
<div class="heatmap-grid">
{yearRows.map((row) => <div class="heatmap-row">
<strong>{row.year}</strong>
{row.weeks.map((cell) => <span
class={`heatmap-cell level-${cell.level}`}
aria-label={`${row.year} 年第 ${cell.week} 周,${cell.words.toLocaleString('zh-CN')} 字`}
>
<span class="heatmap-tooltip" role="tooltip"><span class="heatmap-tooltip-code">{row.year} W{String(cell.week).padStart(2, '0')} · {cell.words.toLocaleString('zh-CN')}</span><span class="heatmap-tooltip-label">字</span></span>
</span>)}
</div>)}
<div class="week-labels" aria-hidden="true">
<span></span>
{Array.from({ length: 53 }, (_, index) => <span>{[1, 14, 27, 40, 53].includes(index + 1) ? `W${String(index + 1).padStart(2, '0')}` : ''}</span>)}
</div>
</div>
</div>
</div>
</section>
<script>
let cleanupHeatmap: (() => void) | undefined;
const initHeatmap = () => {
cleanupHeatmap?.();
cleanupHeatmap = undefined;
const heatmapScroll = document.querySelector<HTMLElement>('[data-heatmap-scroll]');
if (!heatmapScroll) return;
const heatmapViewport = heatmapScroll.closest<HTMLElement>('[data-heatmap-viewport]');
const heatmapGrid = heatmapScroll.querySelector<HTMLElement>('.heatmap-grid');
const root = document.documentElement;
const baseRootFontSize = 16;
let baseCellSize = 0;
let activeTooltip: { cell: HTMLElement; tooltip: HTMLElement; placeholder: Comment } | undefined;
const updateTooltipPosition = () => {
if (!activeTooltip) return;
const { cell, tooltip } = activeTooltip;
const cellRect = cell.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const gap = 6;
const horizontalPadding = 8;
const left = Math.min(
Math.max(cellRect.left + cellRect.width / 2, tooltipRect.width / 2 + horizontalPadding),
window.innerWidth - tooltipRect.width / 2 - horizontalPadding
);
const fitsAbove = cellRect.top >= tooltipRect.height + gap;
const fitsBelow = cellRect.bottom + tooltipRect.height + gap <= window.innerHeight;
const top = fitsAbove || !fitsBelow
? cellRect.top - tooltipRect.height - gap
: cellRect.bottom + gap;
tooltip.style.left = `${left}px`;
tooltip.style.top = `${Math.max(horizontalPadding, top)}px`;
};
const closeTooltip = () => {
if (!activeTooltip) return;
const { tooltip, placeholder } = activeTooltip;
tooltip.removeAttribute('data-heatmap-portal');
tooltip.removeAttribute('style');
placeholder.parentNode?.insertBefore(tooltip, placeholder);
placeholder.remove();
activeTooltip = undefined;
};
const openTooltip = (cell: HTMLElement) => {
closeTooltip();
const tooltip = cell.querySelector<HTMLElement>('.heatmap-tooltip');
if (!tooltip) return;
const placeholder = document.createComment('heatmap-tooltip');
tooltip.replaceWith(placeholder);
tooltip.setAttribute('data-heatmap-portal', '');
document.body.append(tooltip);
activeTooltip = { cell, tooltip, placeholder };
updateTooltipPosition();
requestAnimationFrame(() => tooltip.classList.add('is-visible'));
};
const heatmapCells = [...heatmapScroll.querySelectorAll<HTMLElement>('.heatmap-cell')];
const cellListeners = heatmapCells.map((cell) => {
const onMouseEnter = () => openTooltip(cell);
cell.addEventListener('mouseenter', onMouseEnter);
cell.addEventListener('mouseleave', closeTooltip);
return { cell, onMouseEnter };
});
window.addEventListener('resize', updateTooltipPosition);
window.addEventListener('scroll', updateTooltipPosition, true);
const updateHeatmapFades = () => {
if (!heatmapViewport || !heatmapGrid) return;
const maxScrollLeft = Math.max(0, heatmapGrid.getBoundingClientRect().width - heatmapScroll.clientWidth);
heatmapViewport.classList.toggle('can-scroll-left', heatmapScroll.scrollLeft > 1);
heatmapViewport.classList.toggle('can-scroll-right', heatmapScroll.scrollLeft < maxScrollLeft - 1);
};
const updateHeatmapOverflow = () => {
if (!heatmapGrid) return;
const rootFontSize = Number.parseFloat(getComputedStyle(root).fontSize) || baseRootFontSize;
const scale = rootFontSize / baseRootFontSize;
const availableCellSize = (heatmapScroll.clientWidth - 3.25 * baseRootFontSize) / 53;
if (scale <= 1.01 || baseCellSize === 0) {
baseCellSize = Math.min(12, Math.max(8, availableCellSize));
}
heatmapGrid.style.setProperty('--heatmap-cell-size', `${baseCellSize * scale}px`);
heatmapViewport?.classList.toggle('has-overflow', heatmapGrid.getBoundingClientRect().width > heatmapScroll.clientWidth + 1);
updateHeatmapFades();
};
const resizeObserver = new ResizeObserver(updateHeatmapOverflow);
const fontScaleObserver = new MutationObserver(updateHeatmapOverflow);
resizeObserver.observe(heatmapScroll);
fontScaleObserver.observe(root, { attributes: true, attributeFilter: ['style'] });
heatmapScroll.addEventListener('scroll', updateHeatmapFades, { passive: true });
updateHeatmapOverflow();
cleanupHeatmap = () => {
closeTooltip();
cellListeners.forEach(({ cell, onMouseEnter }) => {
cell.removeEventListener('mouseenter', onMouseEnter);
cell.removeEventListener('mouseleave', closeTooltip);
});
window.removeEventListener('resize', updateTooltipPosition);
window.removeEventListener('scroll', updateTooltipPosition, true);
resizeObserver.disconnect();
fontScaleObserver.disconnect();
heatmapScroll.removeEventListener('scroll', updateHeatmapFades);
};
};
document.addEventListener('astro:page-load', initHeatmap);
initHeatmap();
</script>
<style>
.writing-heatmap { margin-bottom: 3.5rem; }
.writing-heatmap header { display: flex; gap: 1rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.writing-heading { min-width: 0; }
.writing-heatmap h2 { margin: 0; font-size: 1.375rem; font-weight: 400; }
.writing-total { margin: .25rem 0 0; color: var(--color-muted); font-size: .8125rem; }
.writing-total-value { font-family: Consolas, Menlo, Monaco, 'lucida_console', 'Liberation_Mono', 'Courier_New', 'andale_mono', monospaceX, monospace, sans-serif; }
.writing-total-label { font-family: var(--reading-font-family); }
.heatmap-legend { display: flex; gap: .25rem; align-items: center; padding-top: .25rem; color: var(--color-muted); font-size: .75rem; }
.heatmap-legend i { width: .75rem; height: .75rem; }
.heatmap-viewport { position: relative; }
.heatmap-viewport::before,
.heatmap-viewport::after { display: none; position: absolute; z-index: 1; top: 0; bottom: .5rem; width: 1.75rem; content: ''; pointer-events: none; }
.heatmap-viewport::before { left: 0; background: linear-gradient(90deg, var(--color-bg) 15%, transparent); }
.heatmap-viewport::after { right: 0; background: linear-gradient(90deg, transparent, var(--color-bg) 85%); }
.heatmap-viewport.has-overflow.can-scroll-left::before,
.heatmap-viewport.has-overflow.can-scroll-right::after { display: block; }
.heatmap-scroll { overflow-x: hidden; padding: .125rem 0 .5rem; container-type: inline-size; scrollbar-width: thin; }
.heatmap-viewport.has-overflow .heatmap-scroll { overflow-x: auto; }
.heatmap-grid { --heatmap-label-width: 3.25rem; --heatmap-cell-size: clamp(8px, .75rem, 18px); display: grid; width: max-content; gap: 4px; }
.heatmap-row,
.week-labels { display: grid; grid-template-columns: var(--heatmap-label-width) repeat(53, var(--heatmap-cell-size)); column-gap: 0; row-gap: 2px; align-items: center; }
.heatmap-row strong { color: var(--color-text); font-family: Consolas, Menlo, Monaco, 'lucida_console', 'Liberation_Mono', 'Courier_New', 'andale_mono', monospaceX, monospace, sans-serif; font-size: .8125rem; }
.heatmap-cell { position: relative; display: block; width: var(--heatmap-cell-size); height: var(--heatmap-cell-size); }
.heatmap-tooltip { position: absolute; bottom: calc(100% + .375rem); left: 50%; z-index: 10; display: inline-block; padding: .3rem .5rem; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text); box-shadow: var(--shadow-soft); font-size: .75rem; line-height: 1.2; white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, .25rem); transition: opacity 160ms ease, transform 160ms ease; }
.heatmap-cell:hover .heatmap-tooltip { opacity: 1; transform: translate(-50%, 0); }
.heatmap-tooltip[data-heatmap-portal] { position: fixed; top: 0; left: 0; z-index: 1000; bottom: auto; transform: translateX(-50%); opacity: 0; }
.heatmap-tooltip[data-heatmap-portal].is-visible { opacity: 1; }
.heatmap-tooltip-code { font-family: Consolas, Menlo, Monaco, 'lucida_console', 'Liberation_Mono', 'Courier_New', 'andale_mono', monospaceX, monospace, sans-serif; }
.heatmap-tooltip-label { font-family: var(--reading-font-family); }
.week-labels span { color: var(--color-muted); font-family: Consolas, Menlo, Monaco, 'lucida_console', 'Liberation_Mono', 'Courier_New', 'andale_mono', monospaceX, monospace, sans-serif; font-size: .625rem; text-align: center; white-space: nowrap; }
.level-0 { background: var(--heatmap-0); }
.level-1 { background: var(--heatmap-1); }
.level-2 { background: var(--heatmap-2); }
.level-3 { background: var(--heatmap-3); }
.level-4 { background: var(--heatmap-4); }
@media (max-width: 37.5rem) {
.writing-heatmap { display: none; }
}
</style>
+37
View File
@@ -0,0 +1,37 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const baseSchema = z.object({
title: z.string(),
description: z.string().optional(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
draft: z.boolean().default(false),
categories: z.array(z.string()).nullable().transform((value) => value ?? []).default([]),
tags: z.array(z.string()).nullable().transform((value) => value ?? []).default([]),
slug: z.string().optional(),
originalSlug: z.string().optional(),
cover: z.string().optional(),
originalCover: z.string().url().optional(),
hero: z.string().optional(),
textTone: z.enum(['light', 'dark', 'auto']).optional(),
toc: z.union([
z.boolean(),
z.object({ enabled: z.boolean().default(true), position: z.enum(['left', 'right']).default('right') })
]).default(true),
comments: z.boolean().default(false),
math: z.boolean().default(false),
mermaid: z.boolean().default(false)
});
const posts = defineCollection({
loader: glob({ base: './src/content/posts', pattern: '**/*.{md,mdx}' }),
schema: baseSchema
});
const pages = defineCollection({
loader: glob({ base: './src/content/pages', pattern: '**/*.{md,mdx}' }),
schema: baseSchema
});
export const collections = { posts, pages };
+20
View File
@@ -0,0 +1,20 @@
---
title: "测试页面"
description: "主题初始化后创建的测试页面。"
pubDate: "2026-08-11T00:00:00.000Z"
categories: []
tags: []
slug: "test-page"
draft: false
comments: false
toc: true
---
## 测试页面
这是主题初始化后创建的测试页面。
你可以在这里检查独立页面的标题、正文、链接和列表排版:
- [Markdown 排版测试文章](/posts/markdown-test/)
- [Astro 官方网站](https://astro.build/)
+167
View File
@@ -0,0 +1,167 @@
---
title: "Markdown 排版测试"
description: "用于检查主题中常见 Markdown 元素的完整排版效果。"
pubDate: "2026-08-11T00:00:00.000Z"
categories: ['测试分类']
tags: ['测试标签']
slug: "markdown-test"
draft: false
comments: false
math: true
mermaid: true
toc: true
---
# 一级标题
这是一篇用于检查主题排版的测试文章,涵盖常用 Markdown、GFM、数学公式和 Mermaid 图表。
## 二级标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题
## 文本样式
普通文本、**粗体文本**、*斜体文本*、***粗斜体文本***、~~删除线文本~~、`行内代码`
这是第一行,行末使用两个空格强制换行。
这是第二行。
特殊字符可以转义:\*星号\*、\# 井号、\[方括号\]。
## 链接与图片
这是[行内链接](https://astro.build/),这是使用引用的[参考链接][astro],也可以自动识别 <https://astro.build/> 和 <mail@example.com>。
![Astro 标志测试图](/favicon.svg "图片标题")
[astro]: https://astro.build/ "Astro"
## 引用
> 这是一段引用。
>
> > 这是嵌套引用,其中包含 **粗体** 和 `代码`。
## 列表
- 无序列表第一项
- 无序列表第二项
- 二级列表
- 三级列表
1. 有序列表第一项
2. 有序列表第二项
1. 二级有序列表
2. 另一项
- [x] 已完成任务
- [ ] 未完成任务
## 代码
行内代码示例:`const message = 'Hello, Astro';`
```ts
interface Post {
title: string;
published: boolean;
}
const post: Post = {
title: 'Markdown 排版测试',
published: true
};
console.log(post.title);
```
```diff
- const theme = 'old';
+ const theme = 'Eidolon';
```
这是使用四个空格缩进的代码块。
## 表格
| 左对齐 | 居中 | 右对齐 |
| :--- | :---: | ---: |
| 文本 | **粗体** | 100 |
| `code` | [链接](https://astro.build/) | 200 |
## 分隔线
---
分隔线前后的正文用于检查垂直间距。
## 脚注
这句话包含一个脚注。[^note]
[^note]: 这是脚注内容,其中也可以包含 **Markdown**
## 数学公式
行内公式:$E = mc^2$。
块级公式:
$$
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
$$
## Mermaid 图表
```mermaid
flowchart LR
A[Markdown] --> B{渲染成功?}
B -->|是| C[完成]
B -->|否| D[检查样式]
```
## HTML 元素
<details>
<summary>展开原生 HTML 内容</summary>
<p>这是一段位于 details 元素中的内容。</p>
</details>
<kbd>Ctrl</kbd> + <kbd>K</kbd>
## 主题短代码
[hint type="info" title="信息提示"]
这是一个信息提示块,包含 **Markdown 文本**
[/hint]
[collapse title="折叠内容"]
这里是折叠区域中的内容。
- 列表项目
- 另一项目
[/collapse]
[tabs title="选项卡测试"]
[tab name="选项一" selected]
第一个选项卡的内容。
[/tab]
[tab name="选项二"]
第二个选项卡的内容。
[/tab]
[/tabs]
[button href="https://astro.build/"]访问 Astro[/button]
[tag type="success"]成功标签[/tag]
## 段落收尾
最后一段用于观察文章底部留白、正文行高以及中英文混排效果。The quick brown fox jumps over the lazy dog. 0123456789。
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="astro/client" />
interface TwikooClient {
init(options: Record<string, unknown>): Promise<unknown>;
getCommentsCount(options: Record<string, unknown>): Promise<Array<{ url: string; count: number }> | undefined>;
}
interface Window {
twikoo?: TwikooClient;
__miragesTwikoo?: { script?: Promise<TwikooClient>; clientBound?: boolean };
requestIdleCallback?: (callback: () => void) => number;
}
+25
View File
@@ -0,0 +1,25 @@
<svg width="131" height="42" viewBox="0 0 131 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.5 0.5H116C124.008 0.5 130.5 6.99187 130.5 15V41.5H15C6.99187 41.5 0.5 35.0081 0.5 27V0.5Z" fill="black" stroke="#ACACAC"/>
<path d="M17.9605 24.1575C21.4266 26.9643 26.3836 26.9643 29.8497 24.1575L28.5095 22.5026C25.8248 24.6766 21.9854 24.6766 19.3007 22.5026L17.9605 24.1575Z" fill="white"/>
<path d="M19.404 20.5134V17.6365H21.5336V20.5134H19.404Z" fill="white"/>
<path d="M26.012 17.6365V20.5134H28.1415V17.6365H26.012Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M35 21.5C35 27.8513 29.8513 33 23.5 33C17.1487 33 12 27.8513 12 21.5C12 15.1487 17.1487 10 23.5 10C29.8513 10 35 15.1487 35 21.5ZM32.8705 21.5C32.8705 26.6752 28.6752 30.8705 23.5 30.8705C18.3248 30.8705 14.1295 26.6752 14.1295 21.5C14.1295 16.3248 18.3248 12.1295 23.5 12.1295C28.6752 12.1295 32.8705 16.3248 32.8705 21.5Z" fill="white"/>
<path d="M62.844 12.1721L63.0435 12.8831L63.2294 12.8197C63.5169 12.7216 63.8276 12.6155 64.1503 12.5052V15.0552C64.1503 15.1818 64.1005 15.2208 63.9709 15.2208C63.8612 15.2208 63.4922 15.2208 63.0634 15.211C63.1631 15.4156 63.2628 15.7273 63.2928 15.9123C63.901 15.9123 64.28 15.8929 64.5293 15.776C64.7686 15.6591 64.8583 15.4545 64.8583 15.0455V12.2629C65.2106 12.1421 65.5673 12.0195 65.9154 11.8994L65.8057 11.2273C65.4898 11.3348 65.1706 11.4415 64.8583 11.5441V9.63961H65.8057V8.95779H64.8583V7H64.1503V8.95779H62.9936V9.63961H64.1503V11.7732C63.6577 11.9301 63.2064 12.0683 62.844 12.1721Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M67.0322 10.7695C66.3142 10.7695 66.1347 10.5162 66.1347 9.8052V7.39935H68.7474V9.32792H66.763V9.81494C66.763 10.1169 66.8128 10.2045 67.0322 10.2045H68.2986C68.4781 10.2045 68.7474 10.1948 68.9069 10.1656C68.9134 10.2165 68.92 10.2777 68.9268 10.3422C68.941 10.4754 68.9566 10.6224 68.9767 10.7208C68.8371 10.7597 68.5779 10.7695 68.3086 10.7695H67.0322ZM66.763 7.90584H68.1391V8.82143H66.763V7.90584Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.0736 10.7208C69.3656 10.7208 69.1861 10.4773 69.1861 9.76623V7.39935H71.8087V9.2987H69.8144V9.77597C69.8144 10.0682 69.8642 10.1656 70.0936 10.1656H71.4198C71.5993 10.1656 71.8985 10.1461 72.058 10.1071C72.068 10.2825 72.0979 10.5357 72.1179 10.6721C71.9783 10.7208 71.709 10.7208 71.4298 10.7208H70.0736ZM69.8144 7.90584H71.2104V8.79221H69.8144V7.90584Z" fill="white"/>
<path d="M67.83 14.1299C67.2715 14.6753 66.3142 15.1818 65.4567 15.5032C65.6262 15.6201 65.9054 15.8539 66.035 15.9805C66.8727 15.6104 67.8898 14.9968 68.5081 14.3831L67.83 14.1299Z" fill="white"/>
<path d="M69.4554 14.4513C70.2132 14.9091 71.1905 15.5812 71.6791 16L72.2974 15.6104C71.7788 15.1818 70.7916 14.539 70.0437 14.1007L69.4554 14.4513Z" fill="white"/>
<path d="M51.4761 15.9318C50.798 15.5617 49.6612 15.0942 48.5344 14.7338L49.0031 14.2468C50.1299 14.5974 51.3564 15.0552 52.0844 15.4253L51.4761 15.9318Z" fill="white"/>
<path d="M43 15.4156C44.067 15.1623 45.4032 14.6656 46.0713 14.2468L46.7594 14.6753C45.9218 15.1721 44.5855 15.6786 43.5185 15.9513C43.4088 15.8052 43.1596 15.5519 43 15.4156Z" fill="white"/>
<path d="M57.1556 7.21693C57.1571 7.15007 57.1585 7.0868 57.1601 7.02922H57.9877L57.9868 7.0714C57.9788 7.4194 57.968 7.88942 57.9233 8.43665C58.0639 9.58208 58.6711 13.7136 62.2258 15.2597C62.0064 15.4253 61.787 15.6786 61.6773 15.8831C59.1702 14.7391 58.0901 12.3347 57.5998 10.5147C57.13 12.4644 56.0681 14.6445 53.63 15.9221C53.5004 15.7273 53.2511 15.5032 53.0217 15.3474C57.0193 13.3717 57.1185 8.89288 57.1556 7.21693Z" fill="white"/>
<path d="M76.0586 9.43506C76.1376 9.06022 76.2116 8.69288 76.2761 8.3539L75.5283 8.27597C75.3089 9.50325 74.9299 11.1688 74.6408 12.1623L75.3986 12.2305C75.4264 12.1301 75.4552 12.0226 75.4848 11.9091H79.3699C79.3296 12.3408 79.2883 12.7224 79.2455 13.0584H73.0851V13.7403H79.1462C79.0034 14.5883 78.8408 15.0239 78.6395 15.1916C78.5198 15.289 78.4002 15.2987 78.1708 15.2987C77.9215 15.2987 77.2334 15.2987 76.5454 15.2305C76.675 15.4253 76.7747 15.7175 76.7947 15.9221C77.4429 15.961 78.091 15.9708 78.4201 15.9513C78.799 15.9318 79.0284 15.8636 79.2677 15.6396C79.532 15.3814 79.7263 14.8409 79.8969 13.7403H82V13.0584H79.991C80.0434 12.6344 80.0943 12.1472 80.1452 11.5877C80.1552 11.4805 80.1751 11.2565 80.1751 11.2565H75.6483C75.7379 10.8862 75.8303 10.4846 75.92 10.0779H80.2948V9.43506H76.0586Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M66.1746 11.6266V12.25H67.5109V13.3799H65.7558V14.0032H72.1079V13.3799H70.5523V12.25H71.8785V11.6266H70.5523V10.9643H69.8642V11.6266H68.189V10.9838H67.5109V11.6266H66.1746ZM68.189 13.3799V12.25H69.8642V13.3799H68.189Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.1895 14.0909V13.4675H44.6254V9.06494H47.1383V8.4513H43.4687V7.81818H47.1383V7.00974H47.8762V7.81818H51.7852V8.4513H47.8762V9.06494H50.6385V13.4675H52.0445V14.0909H43.1895ZM45.3434 13.4675H49.8906V12.7857H45.3434V13.4675ZM45.3434 12.3182H49.8906V11.7338H45.3434V12.3182ZM45.3434 11.2565H49.8906V10.6818H45.3434V11.2565ZM45.3434 10.224H49.8906V9.58117H45.3434V10.224Z" fill="white"/>
<path d="M73.3245 9.65909V7.52597H81.7607V9.65909H80.9829V8.20779H74.0624V9.65909H73.3245Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M105.32 26.2505C105.32 25.9898 105.336 25.4196 105.336 25.4196H101.424V23.8717H105.573C105.764 26.2238 106.11 28.471 106.651 30.2984C105.776 31.2876 104.759 32.1157 103.612 32.7515C104.107 33.2077 104.953 34.2016 105.288 34.7067C106.138 34.1597 106.93 33.5082 107.658 32.7639C108.338 33.9391 109.193 34.6415 110.254 34.6415C111.898 34.6415 112.649 33.9572 113 30.78C112.377 30.5356 111.563 29.9817 111.036 29.4277C110.956 31.4155 110.765 32.2301 110.445 32.2301C110.06 32.2301 109.67 31.686 109.312 30.7655C110.465 29.1244 111.389 27.1985 112.058 25.0611L109.711 24.4908C109.396 25.6095 108.983 26.659 108.478 27.6243C108.264 26.4884 108.09 25.2105 107.974 23.8717H112.84V21.5418H111.142L111.946 20.6945C111.387 20.1568 110.27 19.4399 109.455 19L108.05 20.4175C108.553 20.7329 109.148 21.1437 109.647 21.5418H107.835C107.811 20.7923 107.808 20.0385 107.827 19.2933H105.384C105.387 20.0364 105.403 20.7892 105.433 21.5418H98.9812V26.4297C98.9812 28.5642 98.9014 31.4481 97.7199 33.3707C98.2468 33.6477 99.3006 34.5275 99.6997 35C100.514 33.7498 100.957 32.0088 101.189 30.2753C101.506 30.8638 101.745 31.7506 101.775 32.4257C102.51 32.4257 103.18 32.4094 103.612 32.3279C104.091 32.2301 104.458 32.0672 104.809 31.5947C105.192 31.0733 105.272 29.5743 105.32 26.2505ZM101.197 30.2141C101.316 29.3052 101.378 28.4 101.406 27.5703H103.058C103.023 29.1922 102.963 29.8644 102.829 30.0631C102.701 30.2261 102.558 30.2749 102.35 30.2749C102.102 30.2749 101.674 30.2596 101.197 30.2141Z" fill="white"/>
<path d="M86.8673 22.112C87.1314 21.4187 87.3672 20.705 87.565 19.9939L85.1541 19.4399C84.6112 21.6395 83.5893 23.8717 82.3599 25.2077C82.9507 25.5336 83.9885 26.2342 84.4515 26.6415C84.9297 26.0332 85.4007 25.2744 85.839 24.4257H88.9062V26.9185H84.7709V29.1996H88.9062V32.002H82.8708V34.3157H97.4005V32.002H91.3332V29.1996H95.9156V26.9185H91.3332V24.4257H96.5543V22.112H91.3332V19.277H88.9062V22.112H86.8673Z" fill="white"/>
<path d="M47.3436 28.4645C47.4377 28.0285 47.4856 27.6141 47.4988 27.2444H43.8462V25.11H47.5026V23.4807H43.6866V21.2811H47.5026V19.3585H49.8018V27C49.8018 29.8513 48.9396 33.0611 45.0278 34.9837C44.6446 34.4134 43.9261 33.6802 43.3672 33.224C44.7784 32.6761 45.7414 31.786 46.3804 30.8057C45.6568 30.9237 44.9507 31.0375 44.2921 31.1436L43.4151 31.2851L43 28.9226C44.1273 28.8236 45.7056 28.6496 47.3436 28.4645Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.6755 34.3305L67.632 19.7976H64.2697L59.1875 34.3305H62.2986L63.2841 31.3431H68.5306L69.4484 34.3305H72.6755ZM65.9218 23.1202L67.6996 28.8388H64.0861L65.9218 23.1202Z" fill="white"/>
<path d="M53.7456 19.3585V21.2811H57.9289V23.4807H53.7456V25.11H57.5457V27.2444H53.7456V28.9063H58.2163V31.1059H53.7456V34.7556H51.4144V19.3585H53.7456Z" fill="white"/>
<path d="M75.2125 22.2696V31.8618H73.4582V34.3339H79.719V31.8618H78.169V22.2696H79.719V19.7976H73.4582V22.2696H75.2125Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg width="131" height="42" viewBox="0 0 131 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.5 0.5H116C124.008 0.5 130.5 6.99187 130.5 15V41.5H15C6.99187 41.5 0.5 35.0081 0.5 27V0.5Z" fill="white" stroke="black"/>
<path d="M17.9605 24.1575C21.4266 26.9643 26.3836 26.9643 29.8497 24.1575L28.5095 22.5026C25.8248 24.6766 21.9854 24.6766 19.3007 22.5026L17.9605 24.1575Z" fill="black"/>
<path d="M19.404 20.5134V17.6365H21.5336V20.5134H19.404Z" fill="black"/>
<path d="M26.012 17.6365V20.5134H28.1415V17.6365H26.012Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M35 21.5C35 27.8513 29.8513 33 23.5 33C17.1487 33 12 27.8513 12 21.5C12 15.1487 17.1487 10 23.5 10C29.8513 10 35 15.1487 35 21.5ZM32.8705 21.5C32.8705 26.6752 28.6752 30.8705 23.5 30.8705C18.3248 30.8705 14.1295 26.6752 14.1295 21.5C14.1295 16.3248 18.3248 12.1295 23.5 12.1295C28.6752 12.1295 32.8705 16.3248 32.8705 21.5Z" fill="black"/>
<path d="M61.844 12.1721L62.0435 12.8831L62.2294 12.8197C62.5169 12.7216 62.8276 12.6155 63.1503 12.5052V15.0552C63.1503 15.1818 63.1005 15.2208 62.9709 15.2208C62.8612 15.2208 62.4922 15.2208 62.0634 15.211C62.1631 15.4156 62.2628 15.7273 62.2928 15.9123C62.901 15.9123 63.28 15.8929 63.5293 15.776C63.7686 15.6591 63.8583 15.4545 63.8583 15.0455V12.2629C64.2106 12.1421 64.5673 12.0195 64.9154 11.8994L64.8057 11.2273C64.4898 11.3348 64.1706 11.4415 63.8583 11.5441V9.63961H64.8057V8.95779H63.8583V7H63.1503V8.95779H61.9936V9.63961H63.1503V11.7732C62.6577 11.9301 62.2064 12.0683 61.844 12.1721Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M66.0322 10.7695C65.3142 10.7695 65.1347 10.5162 65.1347 9.8052V7.39935H67.7474V9.32792H65.763V9.81494C65.763 10.1169 65.8128 10.2045 66.0322 10.2045H67.2986C67.4781 10.2045 67.7474 10.1948 67.9069 10.1656C67.9134 10.2165 67.92 10.2777 67.9268 10.3422C67.941 10.4754 67.9566 10.6224 67.9767 10.7208C67.8371 10.7597 67.5779 10.7695 67.3086 10.7695H66.0322ZM65.763 7.90584H67.1391V8.82143H65.763V7.90584Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.0736 10.7208C68.3656 10.7208 68.1861 10.4773 68.1861 9.76623V7.39935H70.8087V9.2987H68.8144V9.77597C68.8144 10.0682 68.8642 10.1656 69.0936 10.1656H70.4198C70.5993 10.1656 70.8985 10.1461 71.058 10.1071C71.068 10.2825 71.0979 10.5357 71.1179 10.6721C70.9783 10.7208 70.709 10.7208 70.4298 10.7208H69.0736ZM68.8144 7.90584H70.2104V8.79221H68.8144V7.90584Z" fill="black"/>
<path d="M66.83 14.1299C66.2715 14.6753 65.3142 15.1818 64.4567 15.5032C64.6262 15.6201 64.9054 15.8539 65.035 15.9805C65.8727 15.6104 66.8898 14.9968 67.5081 14.3831L66.83 14.1299Z" fill="black"/>
<path d="M68.4554 14.4513C69.2132 14.9091 70.1905 15.5812 70.6791 16L71.2974 15.6104C70.7788 15.1818 69.7916 14.539 69.0437 14.1007L68.4554 14.4513Z" fill="black"/>
<path d="M50.4761 15.9318C49.798 15.5617 48.6612 15.0942 47.5344 14.7338L48.0031 14.2468C49.1299 14.5974 50.3564 15.0552 51.0844 15.4253L50.4761 15.9318Z" fill="black"/>
<path d="M42 15.4156C43.067 15.1623 44.4032 14.6656 45.0713 14.2468L45.7594 14.6753C44.9218 15.1721 43.5855 15.6786 42.5185 15.9513C42.4088 15.8052 42.1596 15.5519 42 15.4156Z" fill="black"/>
<path d="M56.1556 7.21693C56.1571 7.15007 56.1585 7.0868 56.1601 7.02922H56.9877L56.9868 7.0714C56.9788 7.4194 56.968 7.88942 56.9233 8.43665C57.0639 9.58208 57.6711 13.7136 61.2258 15.2597C61.0064 15.4253 60.787 15.6786 60.6773 15.8831C58.1702 14.7391 57.0901 12.3347 56.5998 10.5147C56.13 12.4644 55.0681 14.6445 52.63 15.9221C52.5004 15.7273 52.2511 15.5032 52.0217 15.3474C56.0193 13.3717 56.1185 8.89288 56.1556 7.21693Z" fill="black"/>
<path d="M75.0586 9.43506C75.1376 9.06022 75.2116 8.69288 75.2761 8.3539L74.5283 8.27597C74.3089 9.50325 73.9299 11.1688 73.6408 12.1623L74.3986 12.2305C74.4264 12.1301 74.4552 12.0226 74.4848 11.9091H78.3699C78.3296 12.3408 78.2883 12.7224 78.2455 13.0584H72.0851V13.7403H78.1462C78.0034 14.5883 77.8408 15.0239 77.6395 15.1916C77.5198 15.289 77.4002 15.2987 77.1708 15.2987C76.9215 15.2987 76.2334 15.2987 75.5454 15.2305C75.675 15.4253 75.7747 15.7175 75.7947 15.9221C76.4429 15.961 77.091 15.9708 77.4201 15.9513C77.799 15.9318 78.0284 15.8636 78.2677 15.6396C78.532 15.3814 78.7263 14.8409 78.8969 13.7403H81V13.0584H78.991C79.0434 12.6344 79.0943 12.1472 79.1452 11.5877C79.1552 11.4805 79.1751 11.2565 79.1751 11.2565H74.6483C74.7379 10.8862 74.8303 10.4846 74.92 10.0779H79.2948V9.43506H75.0586Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M65.1746 11.6266V12.25H66.5109V13.3799H64.7558V14.0032H71.1079V13.3799H69.5523V12.25H70.8785V11.6266H69.5523V10.9643H68.8642V11.6266H67.189V10.9838H66.5109V11.6266H65.1746ZM67.189 13.3799V12.25H68.8642V13.3799H67.189Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M42.1895 14.0909V13.4675H43.6254V9.06494H46.1383V8.4513H42.4687V7.81818H46.1383V7.00974H46.8762V7.81818H50.7852V8.4513H46.8762V9.06494H49.6385V13.4675H51.0445V14.0909H42.1895ZM44.3434 13.4675H48.8906V12.7857H44.3434V13.4675ZM44.3434 12.3182H48.8906V11.7338H44.3434V12.3182ZM44.3434 11.2565H48.8906V10.6818H44.3434V11.2565ZM44.3434 10.224H48.8906V9.58117H44.3434V10.224Z" fill="black"/>
<path d="M72.3245 9.65909V7.52597H80.7607V9.65909H79.9829V8.20779H73.0624V9.65909H72.3245Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M105.32 26.2505C105.32 25.9898 105.336 25.4196 105.336 25.4196H101.424V23.8717H105.573C105.764 26.2238 106.11 28.471 106.651 30.2984C105.776 31.2876 104.759 32.1157 103.612 32.7515C104.107 33.2077 104.953 34.2016 105.288 34.7067C106.138 34.1597 106.93 33.5082 107.658 32.7639C108.338 33.9391 109.193 34.6415 110.254 34.6415C111.898 34.6415 112.649 33.9572 113 30.78C112.377 30.5356 111.563 29.9817 111.036 29.4277C110.956 31.4155 110.765 32.2301 110.445 32.2301C110.06 32.2301 109.67 31.686 109.312 30.7655C110.465 29.1244 111.389 27.1985 112.058 25.0611L109.711 24.4908C109.396 25.6095 108.983 26.659 108.478 27.6243C108.264 26.4884 108.09 25.2105 107.974 23.8717H112.84V21.5418H111.142L111.946 20.6945C111.387 20.1568 110.27 19.4399 109.455 19L108.05 20.4175C108.553 20.7329 109.148 21.1437 109.647 21.5418H107.835C107.811 20.7923 107.808 20.0385 107.827 19.2933H105.384C105.387 20.0364 105.403 20.7892 105.433 21.5418H98.9812V26.4297C98.9812 28.5642 98.9014 31.4481 97.7199 33.3707C98.2468 33.6477 99.3006 34.5275 99.6997 35C100.514 33.7498 100.957 32.0088 101.189 30.2753C101.506 30.8638 101.745 31.7506 101.775 32.4257C102.51 32.4257 103.18 32.4094 103.612 32.3279C104.091 32.2301 104.458 32.0672 104.809 31.5947C105.192 31.0733 105.272 29.5743 105.32 26.2505ZM101.197 30.2141C101.316 29.3052 101.378 28.4 101.406 27.5703H103.058C103.023 29.1922 102.963 29.8644 102.829 30.0631C102.701 30.2261 102.558 30.2749 102.35 30.2749C102.102 30.2749 101.674 30.2596 101.197 30.2141Z" fill="black"/>
<path d="M86.8673 22.112C87.1314 21.4187 87.3672 20.705 87.565 19.9939L85.1541 19.4399C84.6112 21.6395 83.5893 23.8717 82.3599 25.2077C82.9507 25.5336 83.9885 26.2342 84.4515 26.6415C84.9297 26.0332 85.4007 25.2744 85.839 24.4257H88.9062V26.9185H84.7709V29.1996H88.9062V32.002H82.8708V34.3157H97.4005V32.002H91.3332V29.1996H95.9156V26.9185H91.3332V24.4257H96.5543V22.112H91.3332V19.277H88.9062V22.112H86.8673Z" fill="black"/>
<path d="M47.3436 28.4645C47.4377 28.0285 47.4856 27.6141 47.4988 27.2444H43.8462V25.11H47.5026V23.4807H43.6866V21.2811H47.5026V19.3585H49.8018V27C49.8018 29.8513 48.9396 33.0611 45.0278 34.9837C44.6446 34.4134 43.9261 33.6802 43.3672 33.224C44.7784 32.6761 45.7414 31.786 46.3804 30.8057C45.6568 30.9237 44.9507 31.0375 44.2921 31.1436L43.4151 31.2851L43 28.9226C44.1273 28.8236 45.7056 28.6496 47.3436 28.4645Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.6755 34.3305L67.632 19.7976H64.2697L59.1875 34.3305H62.2986L63.2841 31.3431H68.5306L69.4484 34.3305H72.6755ZM65.9218 23.1202L67.6996 28.8388H64.0861L65.9218 23.1202Z" fill="black"/>
<path d="M53.7456 19.3585V21.2811H57.9289V23.4807H53.7456V25.11H57.5457V27.2444H53.7456V28.9063H58.2163V31.1059H53.7456V34.7556H51.4144V19.3585H53.7456Z" fill="black"/>
<path d="M75.2125 22.2696V31.8618H73.4582V34.3339H79.719V31.8618H78.169V22.2696H79.719V19.7976H73.4582V22.2696H75.2125Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

+60
View File
@@ -0,0 +1,60 @@
---
import { siteConfig } from '@/site.config';
import BackToTop from '@/components/BackToTop.astro';
import Footer from '@/components/Footer.astro';
import Navbar from '@/components/Navbar.astro';
import SeoHead from '@/components/SeoHead.astro';
import TwikooCommentCounts from '@/components/TwikooCommentCounts.astro';
import TwikooClient from '@/components/TwikooClient.astro';
import NavigationProgress from '@/components/NavigationProgress.astro';
import { ClientRouter } from 'astro:transitions';
import '../styles/global.css';
interface Props { title?: string; description?: string; image?: string; type?: 'website' | 'article'; noindex?: boolean; math?: boolean; navbarOverlay?: boolean; }
const { title, description, image, type, noindex, math, navbarOverlay = false } = Astro.props;
const hasMasthead = Astro.slots.has('masthead');
const appearanceScript = `(function(){var r=document.documentElement,t='mirages-theme',s='mirages-font-scale',f='mirages-font-family',themes=['auto','light','sunset','dark'],fonts=['serif','sans'],stored=Number(localStorage.getItem(s)),scale=stored>=.8&&stored<=1.5?stored*100:stored>=80&&stored<=150?stored:100,theme=localStorage.getItem(t),font=localStorage.getItem(f);scale=Math.round(scale/5)*5;r.dataset.theme=themes.indexOf(theme)>-1?theme:'${siteConfig.appearance.defaultTheme}';r.dataset.font=fonts.indexOf(font)>-1?font:'sans';r.dataset.platform=/Win/.test(navigator.userAgentData?.platform||navigator.platform)?'windows':'other';r.style.fontSize=scale+'%'})();`;
const tabsScript = `document.addEventListener('click',function(event){var button=event.target.closest('[data-shortcode-tab]');if(!button)return;var group=button.closest('[data-shortcode-tabs]');var id=button.dataset.shortcodeTab;group.querySelectorAll('[data-shortcode-tab="'+id+'"]').forEach(function(tab){var active=tab===button;tab.setAttribute('aria-selected',String(active));tab.tabIndex=active?0:-1;var panel=group.querySelector('#'+tab.getAttribute('aria-controls'));if(panel)panel.hidden=!active;});});`;
const collapseScript = `document.querySelectorAll('.shortcode-collapse').forEach(function(details){details.dataset.collapseReady='';details.classList.toggle('is-expanded',details.open);var body=details.querySelector(':scope>.shortcode-body');if(body instanceof HTMLElement&&details.open)body.style.height='auto';});document.addEventListener('click',function(event){var summary=event.target.closest('.shortcode-collapse>summary');if(!summary)return;var details=summary.parentElement;if(!(details instanceof HTMLDetailsElement))return;event.preventDefault();var body=details.querySelector(':scope>.shortcode-body');if(!(body instanceof HTMLElement))return;var expand=!details.classList.contains('is-expanded');details.classList.toggle('is-expanded',expand);if(matchMedia('(prefers-reduced-motion: reduce)').matches){details.open=expand;body.style.height=expand?'auto':'';delete details.dataset.animating;return;}var height=body.getBoundingClientRect().height;details.open=true;body.style.height=height+'px';void body.offsetHeight;details.dataset.animating='true';body.style.height=(expand?body.scrollHeight:0)+'px';});document.addEventListener('transitionend',function(event){var body=event.target;if(!(body instanceof HTMLElement)||!body.classList.contains('shortcode-body')||event.propertyName!=='height')return;var details=body.parentElement;if(!(details instanceof HTMLDetailsElement))return;var expanded=details.classList.contains('is-expanded');details.open=expanded;body.style.height=expanded?'auto':'';delete details.dataset.animating;});`;
---
<!doctype html>
<html class="min-h-full" lang={siteConfig.site.locale} data-theme={siteConfig.appearance.defaultTheme} data-font="sans">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content={siteConfig.appearance.accentColor} />
<link rel="icon" href={siteConfig.site.favicon} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;600;700&display=swap" />
{siteConfig.comments.provider === 'twikoo' && <link rel="stylesheet" href="https://registry.npmmirror.com/twikoo/1.7.15/files/dist/twikoo.css" data-twikoo-styles transition:persist="twikoo-styles" />}
<SeoHead title={title} description={description} image={image} type={type} noindex={noindex} />
{siteConfig.pjax.enabled && <ClientRouter />}
{math && <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.18.1/dist/katex.min.css" />}
<script is:inline set:html={appearanceScript}></script>
</head>
<body
class:list={['flex min-h-[100svh] min-w-80 flex-col bg-page pt-0 font-sans text-foreground transition-colors duration-150', { 'md:pt-nav': !hasMasthead }]}
>
<a class="skip-link" href="#main-content">跳到正文</a>
<Navbar overlay={navbarOverlay} />
<slot name="masthead" />
<main id="main-content" class="min-h-0 flex-1 scroll-mt-0 md:scroll-mt-nav"><slot /></main>
<Footer />
<BackToTop />
<TwikooCommentCounts />
{siteConfig.comments.provider === 'twikoo' && <TwikooClient />}
{siteConfig.pjax.enabled && <NavigationProgress />}
<script is:inline set:html={tabsScript}></script>
<script is:inline set:html={collapseScript}></script>
<script>
import pangu from 'pangu/browser';
const applyPanguSpacing = () => pangu.spacingPage();
applyPanguSpacing();
document.addEventListener('astro:page-load', applyPanguSpacing);
</script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
import { getCollection, type CollectionEntry } from 'astro:content';
export type PostEntry = CollectionEntry<'posts'>;
export type TaxonomyItem = {
name: string;
count: number;
};
export const POSTS_PER_PAGE = 8;
export function getContentCacheKey(scope: string, entries: Array<{ id: string; body?: string; data: unknown; digest?: string | number }>): string {
const fingerprints = entries
.map((entry) => `${entry.id}:${entry.digest ?? JSON.stringify([entry.data, entry.body ?? ''])}`)
.sort()
.join('|');
return `${scope}:${fingerprints}`;
}
export async function getPublicPosts(): Promise<PostEntry[]> {
const posts = await getCollection('posts', ({ data }) => !data.draft);
return posts.sort((left, right) => {
const dateDifference = right.data.pubDate.getTime() - left.data.pubDate.getTime();
return dateDifference || left.id.localeCompare(right.id);
});
}
export function slugify(value: string): string {
return value
.normalize('NFKD')
.trim()
.toLowerCase()
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
.replace(/^-+|-+$/g, '');
}
export function getPostPath(post: PostEntry): string {
const source = post.id.replace(/\\/g, '/').replace(/\.(md|mdx)$/i, '');
const slug = source.split('/').map(slugify).filter(Boolean).join('/');
return `/posts/${slug}/`;
}
export function getPostCardImage(post: PostEntry, defaultCovers: readonly string[], siteUrl: string): string | undefined {
if (post.data.cover) return post.data.cover;
if (post.data.hero) return post.data.hero;
const source = post.body ?? '';
const markdownImage = /!\[[^\]]*\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g;
const htmlImage = /<img\b[^>]*\bsrc\s*=\s*(?:["']([^"']+)["']|([^\s>]+))[^>]*>/gi;
const markdownMatch = markdownImage.exec(source);
const htmlMatch = htmlImage.exec(source);
const firstMatch = !htmlMatch || (markdownMatch && markdownMatch.index < htmlMatch.index) ? markdownMatch : htmlMatch;
const bodyImage = firstMatch?.[1] ?? firstMatch?.[2];
if (bodyImage) {
const base = new URL(getPostPath(post), siteUrl);
const resolved = new URL(bodyImage, base);
return resolved.origin === base.origin ? `${resolved.pathname}${resolved.search}${resolved.hash}` : resolved.href;
}
if (!defaultCovers.length) return undefined;
let hash = 0;
for (const character of post.id) hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
return defaultCovers[hash % defaultCovers.length];
}
export function getExcerpt(post: PostEntry, length = 180): string {
if (post.data.description) return post.data.description;
const text = (post.body ?? '').replace(/```[\s\S]*?```/g, '').replace(/[#*_>`~\[\]()]/g, '').replace(/\s+/g, ' ').trim();
return text.length > length ? `${text.slice(0, length).trim()}...` : text;
}
export function getPostTaxonomy(post: PostEntry): { categories: string[]; tags: string[] } {
return {
categories: [...new Set(post.data.categories)].sort((a, b) => a.localeCompare(b)),
tags: [...new Set(post.data.tags)].sort((a, b) => a.localeCompare(b))
};
}
export function deriveTaxonomy(
posts: PostEntry[],
field: 'categories' | 'tags'
): TaxonomyItem[] {
const counts = new Map<string, number>();
for (const post of posts) {
for (const name of new Set(post.data[field])) {
counts.set(name, (counts.get(name) ?? 0) + 1);
}
}
return [...counts]
.map(([name, count]) => ({ name, count }))
.sort((left, right) => left.name.localeCompare(right.name));
}
export function getTaxonomyPosts(posts: PostEntry[], field: 'categories' | 'tags', slug: string): PostEntry[] {
return posts.filter((post) => post.data[field].some((name) => slugify(name) === slug));
}
export function getArchiveYears(posts: PostEntry[]): Map<number, Map<number, PostEntry[]>> {
const archives = new Map<number, Map<number, PostEntry[]>>();
for (const post of posts) {
const year = post.data.pubDate.getFullYear();
const month = post.data.pubDate.getMonth() + 1;
const months = archives.get(year) ?? new Map<number, PostEntry[]>();
const entries = months.get(month) ?? [];
entries.push(post);
months.set(month, entries);
archives.set(year, months);
}
return archives;
}
+30
View File
@@ -0,0 +1,30 @@
import { visit } from 'unist-util-visit';
export function applyExternalLinks(tree: any, siteUrl: string) {
const siteHostname = new URL(siteUrl).hostname;
visit(tree, 'element', (node: any) => {
if (node.tagName !== 'a' || typeof node.properties?.href !== 'string') return;
try {
const url = new URL(node.properties.href, siteUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
if (url.hostname === siteHostname) {
delete node.properties.target;
delete node.properties.rel;
} else {
node.properties.target = '_blank';
node.properties.rel = 'noopener noreferrer';
}
} catch {
// Keep malformed or non-web links unchanged.
}
});
}
export default function externalLinks(options: { siteUrl: string }) {
return (tree: any) => {
applyExternalLinks(tree, options.siteUrl);
};
}
+283
View File
@@ -0,0 +1,283 @@
import { fromMarkdown } from 'mdast-util-from-markdown';
import { gfmFromMarkdown } from 'mdast-util-gfm';
import { toHtml } from 'hast-util-to-html';
import { toHast } from 'mdast-util-to-hast';
import { gfm } from 'micromark-extension-gfm';
import { visit } from 'unist-util-visit';
import { applyExternalLinks } from './external-links';
const URL_SCHEME = /^(https?:|mailto:|tel:)/i;
const TAG_TYPES = new Set(['primary', 'success', 'warning', 'danger', 'info', 'default']);
const HINT_TYPES = new Set(['warn', 'warning', 'error', 'danger', 'success', 'info']);
const BLOCK_NAMES = new Set(['hint', 'tip', 'collapse', 'tabs', 'tools']);
const INLINE_NAMES = new Set(['button', 'btn', 'file', 'tag', 'label']);
type GithubData = { stars?: string; forks?: string; description?: string; commitDate?: string; htmlUrl?: string; defaultBranch?: string };
let githubData: Record<string, GithubData> = {};
let siteUrl = '';
const escape = (value: string) => value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]!));
function attrs(source: string): Record<string, string> {
const normalized = source.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
const result: Record<string, string> = {};
const pattern = /([\w-]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s\]]+)))?/g;
for (const match of normalized.matchAll(pattern)) result[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? '';
return result;
}
const safeUrl = (value: string | undefined) => {
const candidate = value?.trim();
if (!candidate || candidate.startsWith('//') || /[\u0000-\u001f\u007f]/.test(candidate) || /^(javascript|data|vbscript):/i.test(candidate)) return null;
if (URL_SCHEME.test(candidate) || (candidate.startsWith('/') && !candidate.startsWith('//')) || candidate.startsWith('./') || candidate.startsWith('../') || !/^[\w+.-]+:/.test(candidate)) return candidate;
return null;
};
const safeHttpUrl = (value: string | undefined) => {
const candidate = safeUrl(value);
if (!candidate) return null;
if (!/^[\w+.-]+:/.test(candidate)) return candidate;
if (!/^https?:\/\//i.test(candidate)) return null;
try {
const parsed = new URL(candidate);
return /^https?:$/.test(parsed.protocol) && Boolean(parsed.hostname) ? candidate : null;
} catch {
return null;
}
};
const inline = (value: string) => escape(value).replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>');
function normalizeMarkdownHeadings(source: string): string {
source = source.replace(/^(<!--markdown-->)\s*(?=#{1,6})/gm, '$1\n');
let fence: string | null = null;
return source.split('\n').map((line) => {
const trimmed = line.trim();
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
return line;
}
if (fence) return line;
return line.replace(/^(#{1,6})([^ #])/, '$1 $2');
}).join('\n');
}
function markdown(source: string, preserveHtml = false): string {
const normalized = normalizeMarkdownHeadings(source);
const tree = fromMarkdown(normalized, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
transform(tree, normalized);
if (!preserveHtml) visit(tree, 'html', (node: any) => { if (!node.data?.shortcode) node.value = ''; });
const hast = toHast(tree, { allowDangerousHtml: true }) as any;
if (siteUrl) applyExternalLinks(hast, siteUrl);
return toHtml(hast, { allowDangerousHtml: true });
}
export function renderMarkdown(source: string): string {
return markdown(source, true);
}
function renderBlock(name: string, args: Record<string, string>, body = ''): string {
const label = args.title ?? args.name ?? args.text ?? body.trim().split('\n')[0] ?? '';
if (name === 'button' || name === 'btn' || name === 'file') {
const url = safeUrl(args.href ?? args.url ?? args.link);
if (!url) return `<span class="shortcode-invalid">${escape(label || '链接不可用')}</span>`;
return `<a class="shortcode-button shortcode-${name}" href="${escape(url)}" data-no-text-link>${inline(label || url)}</a>`;
}
if (name === 'tag' || name === 'label') {
const type = TAG_TYPES.has(args.type ?? '') ? args.type : 'default';
const outline = args.outline !== undefined ? ' shortcode-tag-outline' : '';
return `<span class="shortcode-tag shortcode-tag-${type}${outline}">${inline(label)}</span>`;
}
if (name === 'hint' || name === 'tip') {
const type = HINT_TYPES.has(args.type ?? '') ? args.type : 'info';
const title = args.title ? `<strong>${inline(args.title)}</strong>` : '';
return `<aside class="shortcode-hint shortcode-hint-${type}" role="note">${title}${markdown(body)}</aside>`;
}
if (name === 'collapse') {
const open = args.open !== undefined || args.expanded !== undefined ? ' open' : '';
return `<details class="shortcode-collapse"${open}><summary><span class="shortcode-collapse-marker" aria-hidden="true"></span><span>${inline(label || '展开内容')}</span></summary><div class="shortcode-body"><div class="shortcode-body-inner">${markdown(body, true)}</div></div></details>`;
}
if (name === 'tabs') {
const parsed = parseBlocks(body);
const tabs = parsed.filter((item) => item.name === 'tab');
if (!tabs.length) return `<details class="shortcode-collapse"><summary><span class="shortcode-collapse-marker" aria-hidden="true"></span><span>${inline(label || '展开内容')}</span></summary><div class="shortcode-body"><div class="shortcode-body-inner">${markdown(body)}</div></div></details>`;
const id = `shortcode-tabs-${tabs.length}-${Math.random().toString(36).slice(2, 8)}`;
const selected = Math.max(0, tabs.findIndex((tab) => tab.args.selected !== undefined));
const buttons = tabs.map((tab, index) => `<button type="button" role="tab" aria-controls="${id}-panel-${index}" aria-selected="${index === selected}" id="${id}-tab-${index}" data-shortcode-tab="${id}"${index === selected ? '' : ' tabindex="-1"'}>${inline(tab.args.name ?? tab.args.title ?? `选项 ${index + 1}`)}</button>`).join('');
const panels = tabs.map((tab, index) => `<div role="tabpanel" id="${id}-panel-${index}" aria-labelledby="${id}-tab-${index}"${index === selected ? '' : ' hidden'}>${markdown(tab.body)}</div>`).join('');
return `<section class="shortcode-tabs" data-shortcode-tabs="${id}"><div class="shortcode-tab-list" role="tablist" aria-label="${escape(label || '选项卡')}">${buttons}</div>${panels}</section>`;
}
if (name === 'tools') {
const tools = body.split(/\r?\n/).map((line) => {
const match = line.trim().match(/^\[([^\]]+)\]\(([^)]+)\)\+(.+)$/);
if (!match) return null;
const [, name, href, payload] = match;
const parenthesized = payload.match(/^\((.+)\)\/\((.*)\)$/);
const separator = payload.lastIndexOf('/');
const icon = parenthesized?.[1] ?? (separator >= 0 ? payload.slice(0, separator) : '');
const description = parenthesized?.[2] ?? (separator >= 0 ? payload.slice(separator + 1) : '');
if (!icon || !description) return null;
const safeHref = safeUrl(href);
const safeIcon = safeUrl(icon);
if (!safeHref || !safeIcon) return null;
return `<li class="shortcode-tool"><a class="shortcode-tool-link" href="${escape(safeHref)}" target="_blank" rel="noopener noreferrer" data-no-text-link><span class="shortcode-tool-icon"><img src="${escape(safeIcon)}" alt="" loading="lazy"></span><span class="shortcode-tool-content"><strong class="shortcode-tool-name">${inline(name)}</strong><span class="shortcode-tool-description">${inline(description.trim())}</span></span></a></li>`;
}).filter(Boolean).join('');
return `<section class="shortcode-tools" aria-label="${escape(label || '工具')}">${label ? `<h3 class="shortcode-tools-title">${inline(label)}</h3>` : ''}<ul class="shortcode-tool-list">${tools}</ul></section>`;
}
if (name === 'github') {
const repository = (args.repo ?? args.repository ?? label).trim();
const repositoryMatch = repository.match(/^([A-Za-z0-9](?:[A-Za-z0-9_.-]{0,38}))\/([A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99}))$/);
if (!repositoryMatch) return '<span class="shortcode-invalid">GitHub 仓库地址不可用</span>';
const [, owner, repo] = repositoryMatch;
const data = githubData[repository.toLowerCase()] ?? {};
const url = safeHttpUrl(data.htmlUrl) ?? `https://github.com/${owner}/${repo}`;
const readMore = args.readmore === undefined ? url : safeHttpUrl(args.readmore);
const branch = data.defaultBranch || 'main';
const download = args.download === undefined ? `${url}/archive/refs/heads/${encodeURIComponent(branch)}.zip` : safeHttpUrl(args.download);
const description = args.description !== undefined ? args.description.trim() : data.description?.trim();
const commitDate = args.lastcommit !== undefined ? args.lastcommit.trim() : args.commitdate !== undefined ? args.commitdate.trim() : data.commitDate?.trim();
const stars = args.stars !== undefined ? args.stars.trim() : data.stars;
const forks = args.forks !== undefined ? args.forks.trim() : data.forks;
const stats = (name: string, value: string | undefined) => `<span class="shortcode-github-stat${value?.trim() ? '' : ' shortcode-github-stat-empty'}"><span>${name}</span> <b>${escape(value?.trim() || '—')}</b></span>`;
const descriptionMarkup = description ? `<p class="shortcode-github-description">${inline(description)}</p>` : '';
const readMoreMarkup = readMore ? `<a class="shortcode-github-read-more" href="${escape(readMore)}" target="_blank" rel="noopener noreferrer" data-no-text-link>Read More</a>` : '';
const commitMarkup = `<span class="shortcode-github-commit${commitDate ? '' : ' shortcode-github-commit-empty'}"><span class="shortcode-github-commit-label">Last Commit</span> <time class="shortcode-github-date">${escape(commitDate || '—')}</time></span>`;
const downloadMarkup = download ? `<a class="shortcode-github-download" href="${escape(download)}" download target="_blank" rel="noopener noreferrer" data-no-text-link>Download as zip</a>` : '';
const contentMarkup = descriptionMarkup || readMoreMarkup ? `<div class="shortcode-github-content">${descriptionMarkup}${readMoreMarkup}</div>` : '';
const footerMarkup = commitMarkup || downloadMarkup ? `<footer class="shortcode-github-footer">${commitMarkup}${downloadMarkup}</footer>` : '';
return `<article class="shortcode-github" aria-label="GitHub repository ${escape(repository)}"><header class="shortcode-github-header"><div class="shortcode-github-title"><svg class="shortcode-github-icon" aria-hidden="true" viewBox="0 0 16 16" role="img"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8" /></svg><h3><a class="shortcode-github-owner" href="https://github.com/${escape(owner)}" target="_blank" rel="noopener noreferrer" data-no-text-link>${escape(owner)}</a>/<a class="shortcode-github-repo" href="${url}" target="_blank" rel="noopener noreferrer" data-no-text-link>${escape(repo)}</a></h3></div><div class="shortcode-github-stats">${stats('Stars', stars)}${stats('Forks', forks)}</div></header>${contentMarkup}${footerMarkup}</article>`;
}
return '';
}
function parseBlocks(source: string): Array<{ name: string; args: Record<string, string>; body: string }> {
const result: Array<{ name: string; args: Record<string, string>; body: string }> = [];
const pattern = /^\[tab([^\]]*)\]\s*\n?([\s\S]*?)^\[\/tab\]\s*$/gm;
for (const match of source.matchAll(pattern)) result.push({ name: 'tab', args: attrs(match[1]), body: match[2] });
return result;
}
function findGithubBlocks(source: string): Array<{ name: string; args: Record<string, string>; body: string; start: number; end: number }> {
const result: Array<{ name: string; args: Record<string, string>; body: string; start: number; end: number }> = [];
let fence: string | null = null;
let opening: { args: Record<string, string>; start: number; bodyStart: number } | null = null;
for (const line of source.matchAll(/^[^\n]*(?:\n|$)/gm)) {
const text = line[0].trim();
const start = line.index!;
const fenceMatch = text.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
continue;
}
if (fence) continue;
const selfClosing = text.match(/^\[github\b([\s\S]*?)\/\s*\]$/i);
if (selfClosing) {
result.push({ name: 'github', args: attrs(selfClosing[1]), body: '', start, end: start + line[0].length });
continue;
}
const closing = text.match(/^\[\/github\]\s*$/i);
if (closing && opening) {
result.push({ name: 'github', args: opening.args, body: source.slice(opening.bodyStart, start), start: opening.start, end: start + line[0].length });
opening = null;
continue;
}
const startTag = text.match(/^\[github\b([^\]]*)\]\s*$/i);
if (startTag) opening = { args: attrs(startTag[1]), start, bodyStart: start + line[0].length };
}
return result;
}
function findBlocks(source: string): Array<{ name: string; args: Record<string, string>; body: string; start: number; end: number }> {
const result: Array<{ name: string; args: Record<string, string>; body: string; start: number; end: number }> = [];
const stack: Array<{ name: string; args: Record<string, string>; start: number; bodyStart: number }> = [];
let fence: string | null = null;
for (const line of source.matchAll(/^[^\n]*(?:\n|$)/gm)) {
const text = line[0].trim();
const start = line.index!;
const fenceMatch = text.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
continue;
}
if (fence) continue;
const opening = text.match(/^\[([\w!-]+)([^\]]*)\]$/);
const closing = text.match(/^\[\/([\w!-]+)\]$/);
if (closing) {
const name = closing[1].toLowerCase();
const current = stack[stack.length - 1];
if (current?.name === name) { stack.pop(); result.push({ name: current.name, args: current.args, body: source.slice(current.bodyStart, start), start: current.start, end: start + line[0].length }); }
} else if (opening) {
const name = opening[1].toLowerCase();
if (BLOCK_NAMES.has(name)) stack.push({ name, args: attrs(opening[2]), start, bodyStart: start + line[0].length });
else if (INLINE_NAMES.has(name)) result.push({ name, args: attrs(opening[2]), body: '', start, end: start + line[0].length });
}
}
return [...findGithubBlocks(source), ...result].sort((a, b) => a.start - b.start);
}
function replaceInline(source: string): string | null {
const noticePattern = /^\[!(?:\/\s*)?\]\s*(.+)$/gm;
let replacedNotice = false;
source = source.replace(noticePattern, (_match, body) => {
replacedNotice = true;
return `<span class="shortcode-notice" role="note">${inline(body)}</span>`;
});
const shortcode = /\[(button|btn|file|tag|label)([^\]]*)\]([\s\S]*?)\[\/\1\]/gi;
const replacedShortcode = shortcode.test(source);
if (!replacedNotice && !replacedShortcode) return null;
return source.replace(shortcode, (_match, name, rawArgs, body) => renderBlock(name.toLowerCase(), attrs(rawArgs), body));
}
function transform(tree: any, raw = '') {
visit(tree, 'root', (root: any) => {
let fence: string | null = null;
for (const line of raw.matchAll(/^[^\n]*(?:\n|$)/gm)) {
const text = line[0].trim();
const fenceMatch = text.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
} else if (!fence && /\[(?:\/)?hide(?:\s[^\]]*)?\]/i.test(text)) {
throw new Error('Unsupported shortcode [hide]: hide has been removed from Mirages Astro.');
}
}
const blocks = findBlocks(raw).filter((block, _index, all) => !all.some((other) => other !== block && other.start < block.start && other.end >= block.end));
if (blocks.length) {
const replaced = new Set<number>();
for (const block of blocks) {
const indexes = root.children.map((node: any, index: number) => ({ node, index })).filter(({ node }: any) => node.position && node.position.start.offset < block.end && node.position.end.offset > block.start).map(({ index }: any) => index);
const first = indexes[0];
if (first === undefined) continue;
root.children[first] = { type: 'html', value: renderBlock(block.name, block.args, block.body), data: { shortcode: true } };
indexes.slice(1).forEach((index: number) => replaced.add(index));
}
root.children = root.children.filter((_node: any, index: number) => !replaced.has(index));
}
root.children = root.children.map((node: any) => {
if (node.type !== 'paragraph' || !node.position) return node;
const source = raw.slice(node.position.start.offset, node.position.end.offset).trim();
const heading = source.match(/^#{1,6}([^ #].*)$/);
if (!heading || /\n/.test(source)) return node;
const parsed = fromMarkdown(`${source.slice(0, source.length - heading[1].length)} ${heading[1]}`);
return parsed.children[0]?.type === 'heading' ? parsed.children[0] : node;
});
const output: any[] = [];
for (const node of root.children) {
if (node.type !== 'paragraph' && node.type !== 'html') { output.push(node); continue; }
const source = node.type === 'html' ? node.value : node.position ? raw.slice(node.position.start.offset, node.position.end.offset) : '';
const replaced = replaceInline(source.trim());
output.push(replaced ? { type: 'html', value: replaced, data: { shortcode: true } } : node);
}
root.children = output;
});
}
export default function shortcodes(options: { githubData?: Record<string, GithubData>; siteUrl?: string } = {}) {
githubData = options.githubData ?? {};
siteUrl = options.siteUrl ?? '';
return (tree: any, file: any) => transform(tree, file?.toString?.() ?? String(file?.value ?? ''));
}
View File
+8
View File
@@ -0,0 +1,8 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
---
<BaseLayout title="页面未找到" noindex>
<section class="content-shell flex min-h-[55vh] flex-col items-center justify-center py-16 text-center" data-pagefind-ignore="all">
<p class="m-0 font-bold text-mirages-accent">404</p><h1 class="mt-2 mb-3 text-[2rem] leading-tight font-light tracking-[0]">页面未找到</h1><span class="mb-6 text-subtle">地址可能已经改变,或者这个页面已经不存在了。</span><a class="rounded-[4px] bg-mirages-accent px-6 py-2 text-sm font-bold text-white no-underline hover:text-white" href="/">返回首页</a>
</section>
</BaseLayout>
+37
View File
@@ -0,0 +1,37 @@
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '@/layouts/BaseLayout.astro';
import Masthead from '@/components/Masthead.astro';
import TableOfContents from '@/components/TableOfContents.astro';
import Mermaid from '@/components/Mermaid.astro';
import ImageLightbox from '@/components/ImageLightbox.astro';
import { getContentCacheKey } from '@/lib/content';
export async function getStaticPaths() {
const reservedRoutes = new Set(['archives', 'categories', 'links', 'page', 'posts', 'search', 'tags']);
const pages = await getCollection('pages', ({ data }) => !data.draft);
return pages.flatMap((page) => {
const slug = (page.data.slug ?? page.id).replace(/\\/g, '/').replace(/\.(md|mdx)$/i, '').replace(/^\/+|\/+$/g, '');
return !slug || reservedRoutes.has(slug.split('/')[0]) ? [] : [{ params: { slug }, props: { page }, cacheKey: getContentCacheKey('page', [page]) }];
});
}
const { page } = Astro.props;
const { Content, headings } = await render(page);
const toc = typeof page.data.toc === 'boolean' ? { enabled: page.data.toc, position: 'right' as const } : page.data.toc;
const slug = (page.data.slug ?? page.id).replace(/\\/g, '/').replace(/\.(md|mdx)$/i, '').replace(/^\/+|\/+$/g, '');
const isAbout = slug === 'about';
---
<BaseLayout title={page.data.title} description={page.data.description} image={page.data.hero ?? page.data.cover} math={page.data.math} navbarOverlay={!isAbout && Boolean(page.data.hero ?? page.data.cover)}>
<Fragment slot="masthead">
<Masthead
about={isAbout}
textTone={page.data.textTone}
banner={{ title: page.data.title, subtitle: page.data.description, image: page.data.hero ?? page.data.cover, textTone: page.data.textTone }}
/>
</Fragment>
<article class:list={['article-shell', 'relative pt-8 pb-14 md:pt-10 md:pb-20', { 'has-toc': toc.enabled, 'about-page': isAbout }]}>
{toc.enabled && <TableOfContents headings={headings} position={toc.position} />}
<div class="prose" data-pagefind-body><Content /></div>
<ImageLightbox />
</article>
<Mermaid enabled={page.data.mermaid} />
</BaseLayout>
+81
View File
@@ -0,0 +1,81 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import Masthead from '@/components/Masthead.astro';
import WritingHeatmap from '@/components/WritingHeatmap.astro';
import { deriveTaxonomy, getArchiveYears, getPostPath, getPublicPosts, slugify } from '@/lib/content';
import { siteConfig } from '@/site.config';
const posts = await getPublicPosts();
const archives = getArchiveYears(posts);
const tagLimit = siteConfig.archives.tagLimit;
const tags = deriveTaxonomy(posts, 'tags')
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
const visibleTags = tagLimit === 0 ? tags : tags.slice(0, Math.max(0, tagLimit));
---
<BaseLayout title="归档" description="按日期和主题整理的文章。">
<Fragment slot="masthead"><Masthead banner={{ title: '归档', subtitle: '按日期和主题整理的文章。',image: 'https://image.luming.cool/i/2026/06/06/6a23f222a1641.webp', textTone: 'auto' }} /></Fragment>
<div class="article-shell archives-shell pt-8 pb-14 md:pt-10 md:pb-20">
<WritingHeatmap posts={posts} />
<section class="archives-tags" aria-labelledby="tag-cloud-title" data-pagefind-ignore="all">
<h2 id="tag-cloud-title">标签云</h2>
<div class="tag-cloud">{visibleTags.map((tag) => <a href={`/tags/${slugify(tag.name)}/`}>{tag.name}</a>)}</div>
</section>
<section class="archives-content" aria-label="文章归档">
{[...archives].map(([year, months]) => <section class="archive-year">
<h2>{year} 年</h2>
{[...months].map(([month, entries]) => <div class="archive-month">
<h3>{String(month).padStart(2, '0')} 月</h3>
<ol>{entries.map((post) => <li><a class="archive-entry" data-archive-entry data-no-text-link href={getPostPath(post)}><time datetime={post.data.pubDate.toISOString()}>{post.data.pubDate.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }).replaceAll('/', '-')}</time><span class="archive-entry-title">{post.data.title}</span></a></li>)}</ol>
</div>)}
</section>)}
</section>
</div>
</BaseLayout>
<style>
.archives-tags { margin-bottom: 3.5rem; }
.archives-tags h2,
.archive-year > h2,
.archive-month h3 { font-weight: 400; }
.archives-tags h2 { margin: 0 0 1rem; font-size: 1.375rem; }
.tag-cloud { display: flex; flex-wrap: wrap; gap: .625rem 1.25rem; font-size: 1rem; line-height: 1.75; }
.tag-cloud a { position: relative; color: var(--color-text); }
.tag-cloud a::after { position: absolute; right: 0; bottom: -.12em; left: 0; border-bottom: 1px solid var(--color-accent); content: ''; transform: scaleX(0); transform-origin: left center; transition: transform 180ms ease-out; }
.tag-cloud a:is(:hover, :focus-visible) { color: var(--color-accent); }
.tag-cloud a:is(:hover, :focus-visible)::after { transform: scaleX(1); }
.archive-year { padding: 1.75rem 0; }
.archive-year:first-child { padding-top: 0; }
.archive-year > h2 { margin: 0 0 1.25rem; color: var(--color-accent); font-size: 1.625rem; }
.archive-month { margin-bottom: 1.5rem; }
.archive-month h3 { margin: 0 0 .625rem; font-size: 1.1875rem; }
.archive-month ol { margin: 0; padding: 0; list-style: none; }
.archive-entry {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: 1rem;
align-items: baseline;
padding: .5rem .75rem;
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 1.0625rem;
line-height: 1.75;
transition: background-color var(--transition-fast);
}
.archive-entry:hover,
.archive-entry:focus-visible {
background-color: var(--color-archive-hover);
}
.archive-entry time {
color: var(--color-muted);
font-size: .9375rem;
}
@media (max-width: 37.5rem) {
.archives-tags { margin-bottom: 2.75rem; }
.tag-cloud { gap: .5rem 1rem; font-size: .9375rem; }
.archive-year > h2 { font-size: 1.5rem; }
.archive-month h3 { font-size: 1.125rem; }
.archive-entry { grid-template-columns: 58px minmax(0, 1fr); gap: .75rem; padding-inline: .5rem; font-size: 1rem; }
}
</style>
@@ -0,0 +1,22 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import Masthead from '@/components/Masthead.astro';
import PostList from '@/components/PostList.astro';
import { deriveTaxonomy, getContentCacheKey, getPublicPosts, getTaxonomyPosts, POSTS_PER_PAGE, slugify } from '@/lib/content';
import { categoryBanners, resolveTaxonomyBanner } from '@/category-banner.config';
export async function getStaticPaths() {
const posts = await getPublicPosts();
return deriveTaxonomy(posts, 'categories').map(({ name }) => {
const category = slugify(name);
const entries = getTaxonomyPosts(posts, 'categories', category);
return { params: { category }, props: { name, posts: entries }, cacheKey: getContentCacheKey(`category:${category}`, entries) };
});
}
const { name, posts } = Astro.props;
const total = Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE));
const banner = resolveTaxonomyBanner(categoryBanners, slugify(name), { title: name, subtitle: `归入“${name}”分类的文章。` });
---
<BaseLayout title={`分类:${name}`} description={`归入“${name}”分类的文章。`} navbarOverlay={Boolean(banner.image)}>
<Fragment slot="masthead"><Masthead banner={banner} textTone={banner.textTone} /></Fragment>
<PostList posts={posts.slice(0, POSTS_PER_PAGE)} total={total} basePath={`/categories/${slugify(name)}`} />
</BaseLayout>
@@ -0,0 +1,21 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import Masthead from '@/components/Masthead.astro';
import PostList from '@/components/PostList.astro';
import { deriveTaxonomy, getContentCacheKey, getPublicPosts, getTaxonomyPosts, POSTS_PER_PAGE, slugify } from '@/lib/content';
import { categoryBanners, resolveTaxonomyBanner } from '@/category-banner.config';
export async function getStaticPaths() {
const posts = await getPublicPosts();
return deriveTaxonomy(posts, 'categories').flatMap(({ name }) => {
const entries = getTaxonomyPosts(posts, 'categories', slugify(name));
const total = Math.ceil(entries.length / POSTS_PER_PAGE);
return Array.from({ length: Math.max(0, total - 1) }, (_, index) => { const page = index + 2; const category = slugify(name); return { params: { category, page: String(page) }, props: { name, posts: entries.slice((page - 1) * POSTS_PER_PAGE, page * POSTS_PER_PAGE), page, total }, cacheKey: getContentCacheKey(`category:${category}:${page}`, entries) }; });
});
}
const { name, posts, page, total } = Astro.props;
const banner = resolveTaxonomyBanner(categoryBanners, slugify(name), { title: name, subtitle: `归入“${name}”分类的文章。` });
---
<BaseLayout title={`${name} - 第 ${page} 页`} navbarOverlay={Boolean(banner.image)}>
<Fragment slot="masthead"><Masthead banner={banner} textTone={banner.textTone} /></Fragment>
<PostList posts={posts} current={page} total={total} basePath={`/categories/${slugify(name)}`} />
</BaseLayout>
+14
View File
@@ -0,0 +1,14 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import Masthead from '@/components/Masthead.astro';
import PostList from '@/components/PostList.astro';
import { getPublicPosts, POSTS_PER_PAGE } from '@/lib/content';
import { siteConfig } from '@/site.config';
const posts = await getPublicPosts();
const total = Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE));
---
<BaseLayout navbarOverlay={Boolean(siteConfig.banner.image)}>
<Fragment slot="masthead"><Masthead /></Fragment>
<PostList posts={posts.slice(0, POSTS_PER_PAGE)} total={total} basePath="" />
</BaseLayout>
+20
View File
@@ -0,0 +1,20 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import Masthead from '@/components/Masthead.astro';
import PostList from '@/components/PostList.astro';
import { getContentCacheKey, getPublicPosts, POSTS_PER_PAGE } from '@/lib/content';
import { siteConfig } from '@/site.config';
export async function getStaticPaths() {
const posts = await getPublicPosts();
const total = Math.ceil(posts.length / POSTS_PER_PAGE);
return Array.from({ length: Math.max(0, total - 1) }, (_, index) => {
const page = index + 2;
return { params: { page: String(page) }, props: { posts: posts.slice((page - 1) * POSTS_PER_PAGE, page * POSTS_PER_PAGE), page, total }, cacheKey: getContentCacheKey(`page:${page}`, posts) };
});
}
const { posts, page, total } = Astro.props;
---
<BaseLayout title={`文章 - 第 ${page} 页`} navbarOverlay={Boolean(siteConfig.banner.image)}>
<Fragment slot="masthead"><Masthead /></Fragment>
<PostList posts={posts} current={page} total={total} basePath="" />
</BaseLayout>
+54
View File
@@ -0,0 +1,54 @@
---
import { render } from 'astro:content';
import BaseLayout from '@/layouts/BaseLayout.astro';
import Masthead from '@/components/Masthead.astro';
import { getContentCacheKey, getExcerpt, getPostCardImage, getPostPath, getPostTaxonomy, getPublicPosts, slugify } from '@/lib/content';
import { siteConfig } from '@/site.config';
import TwikooComments from '@/components/TwikooComments.astro';
import TableOfContents from '@/components/TableOfContents.astro';
import Mermaid from '@/components/Mermaid.astro';
import ImageLightbox from '@/components/ImageLightbox.astro';
export async function getStaticPaths() {
const posts = await getPublicPosts();
return posts.map((post, index) => ({
params: { slug: getPostPath(post).replace(/^\/posts\//, '').replace(/\/$/, '') },
props: { post, newer: posts[index - 1], older: posts[index + 1] },
cacheKey: getContentCacheKey('post', [post, posts[index - 1], posts[index + 1]].filter(Boolean))
}));
}
const { post, newer, older } = Astro.props;
const { Content, headings } = await render(post);
const cover = getPostCardImage(post, siteConfig.cards.defaultCovers, siteConfig.site.url);
const toc = typeof post.data.toc === 'boolean' ? { enabled: post.data.toc, position: 'right' as const } : post.data.toc;
const taxonomy = getPostTaxonomy(post);
const tags = taxonomy.tags;
const date = {
year: String(post.data.pubDate.getFullYear()),
month: String(post.data.pubDate.getMonth() + 1).padStart(2, '0'),
day: String(post.data.pubDate.getDate()).padStart(2, '0')
};
const dateText = `${date.year} 年 ${date.month} 月 ${date.day} 日`;
---
<BaseLayout title={post.data.title} description={getExcerpt(post)} image={cover} type="article" math={post.data.math} navbarOverlay={Boolean(cover)}>
<Fragment slot="masthead"><Masthead article={{ title: post.data.title, date, categories: taxonomy.categories, image: cover, textTone: post.data.textTone }} /></Fragment>
<article class:list={['article-shell', 'relative pt-8 pb-14 md:pt-10 md:pb-20', { 'has-toc': toc.enabled, [`toc-${toc.position}`]: toc.enabled }]}>
<div class="sr-only" aria-hidden="true">
<time data-pagefind-meta="date" datetime={post.data.pubDate.toISOString()}>{dateText}</time>
{taxonomy.categories.length > 0 && <span data-pagefind-meta="categories">{taxonomy.categories.join(', ')}</span>}
{tags.length > 0 && <span data-pagefind-meta="tags">{tags.join(', ')}</span>}
{cover && <span data-pagefind-meta="cover">{cover}</span>}
</div>
{toc.enabled && <TableOfContents headings={headings} position={toc.position} />}
<div class="prose" data-pagefind-body><Content /></div>
<ImageLightbox />
{tags.length > 0 && <div class="mt-10 flex flex-wrap justify-center gap-2 border-t border-line pt-5 text-sm" aria-label="标签">
{tags.map((tag) => <a class="rounded-[3px] bg-[var(--color-post-tag-bg)] px-2.5 py-1 no-underline" href={`/tags/${slugify(tag)}/`}>#{tag}</a>)}
</div>}
<nav class="mt-10 grid grid-cols-1 overflow-hidden rounded-[4px] border border-line bg-raised sm:grid-cols-2" aria-label="相邻文章" data-pagefind-ignore="all">
{newer ? <a class="grid min-h-24 content-center p-4 no-underline sm:border-r sm:border-line" href={getPostPath(newer)}><span class="text-xs text-subtle">下一篇</span><strong class="mt-1 font-sans text-base font-normal">{newer.data.title}</strong></a> : <span class="grid min-h-24 content-center p-4 sm:border-r sm:border-line"><span class="text-xs text-subtle">下一篇</span><strong class="mt-1 font-sans text-base font-normal">没有更多了</strong></span>}
{older ? <a class="grid min-h-24 content-center border-t border-line p-4 no-underline sm:border-t-0 sm:text-right" href={getPostPath(older)}><span class="text-xs text-subtle">上一篇</span><strong class="mt-1 font-sans text-base font-normal">{older.data.title}</strong></a> : <span class="grid min-h-24 content-center border-t border-line p-4 sm:border-t-0 sm:text-right"><span class="text-xs text-subtle">上一篇</span><strong class="mt-1 font-sans text-base font-normal">没有更多了</strong></span>}
</nav>
<TwikooComments enabled={post.data.comments} path={Astro.url.pathname} />
</article>
<Mermaid enabled={post.data.mermaid} />
</BaseLayout>
+3
View File
@@ -0,0 +1,3 @@
import type { APIRoute } from 'astro';
export const GET: APIRoute = ({ site }) => new Response(`User-agent: *\nAllow: /\nSitemap: ${new URL('sitemap-index.xml', site)}`);
+20
View File
@@ -0,0 +1,20 @@
import rss from '@astrojs/rss';
import { siteConfig } from '@/site.config';
import { getExcerpt, getPostPath, getPublicPosts } from '@/lib/content';
import { renderMarkdown } from '@/lib/shortcodes';
export async function GET(context) {
const posts = (await getPublicPosts()).slice(0, 10);
return rss({
title: siteConfig.site.title,
description: siteConfig.site.description,
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
description: getExcerpt(post),
content: renderMarkdown(post.body ?? ''),
pubDate: post.data.pubDate,
link: getPostPath(post)
}))
});
}
+110
View File
@@ -0,0 +1,110 @@
---
import { Search as SearchIcon } from '@lucide/astro';
import BaseLayout from '@/layouts/BaseLayout.astro';
const query = Astro.url.searchParams.get('q')?.trim() ?? '';
---
<BaseLayout title={query ? `搜索:${query}` : '搜索'} description="搜索 Mirages 中的文章。" noindex>
<section class="mx-auto w-full px-[48px] pt-16 pb-14 max-[336px]:px-5 md:max-w-[720px] md:px-0 md:pt-20 min-[1302px]:max-w-[864px] min-[1600px]:max-w-[896px] min-[1800px]:max-w-[960px] min-[2000px]:max-w-[992px] min-[2400px]:max-w-[1024px]" data-search-page>
<header class="mb-8 border-b border-line pb-4">
<h1 class="m-0 text-xl leading-tight font-normal">搜索结果</h1>
<p class="mt-2 mb-0 text-sm text-subtle" data-search-query>{query ? `查询词:${query}` : '请输入查询词开始搜索。'}</p>
</header>
<form class="mb-8 flex items-center border-b border-line text-current" action={`${import.meta.env.BASE_URL}search/`} method="get" role="search">
<SearchIcon class="shrink-0 text-subtle" aria-hidden="true" size={17} />
<input class="min-w-0 flex-1 border-0 bg-transparent px-2 py-2 text-sm text-current outline-none focus:border-0 focus:outline-none focus:ring-0" type="search" name="q" value={query} placeholder="搜索文章" required aria-label="搜索文章" />
<button class="border-0 bg-transparent px-2 py-2 text-sm text-mirages-accent hover:bg-raised" type="submit">搜索</button>
</form>
<div data-search-page-results aria-live="polite">
{query ? <p class="px-3 py-4 text-sm text-subtle">正在搜索...</p> : <p class="px-3 py-4 text-sm text-subtle">请输入查询词开始搜索。</p>}
</div>
</section>
</BaseLayout>
<script>
interface PagefindResult { data: () => Promise<{ url: string; meta?: { title?: string; date?: string; category?: string; categories?: string; tags?: string; cover?: string } }>; }
interface PagefindModule { options: (options: { excerptLength: number }) => Promise<void>; search: (query: string) => Promise<{ results: PagefindResult[] }>; }
const root = document.querySelector<HTMLElement>('[data-search-page]');
const results = root?.querySelector<HTMLElement>('[data-search-page-results]');
const queryLabel = root?.querySelector<HTMLElement>('[data-search-query]');
const input = root?.querySelector<HTMLInputElement>('input[name="q"]');
const query = new URLSearchParams(window.location.search).get('q')?.trim() ?? '';
if (query) {
if (queryLabel) queryLabel.textContent = `查询词:${query}`;
if (input) input.value = query;
}
const cardBackgrounds = ['#547a82', '#806b76', '#657657', '#876d56', '#5f718d', '#796f54'];
const cardBackground = (url: string) => {
let hash = 0;
for (const character of url) hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
return cardBackgrounds[hash % cardBackgrounds.length];
};
const run = async () => {
if (!query || !results) return;
if (import.meta.env.DEV) { results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">搜索功能需在构建并预览后使用。</p>'; return; }
try {
const pagefindUrl = new URL(`${import.meta.env.BASE_URL}pagefind/pagefind.js`, document.baseURI).href;
const pagefind = await import(/* @vite-ignore */ pagefindUrl) as unknown as PagefindModule;
await pagefind.options({ excerptLength: 0 });
const search = await pagefind.search(query);
results.replaceChildren();
if (!search.results.length) {
results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">没有找到相关内容。</p>';
return;
}
for (const result of search.results) {
const data = await result.data();
const article = document.createElement('article');
article.className = 'py-[0.9375rem] md:py-[0.9375rem]';
const link = document.createElement('a');
link.className = 'group relative block h-50 overflow-hidden rounded-[0.3125rem] bg-[var(--card-background)] text-white no-underline shadow-sm transition-[transform,box-shadow] duration-200 ease-out hover:-translate-y-0.5 hover:scale-[1.005] hover:text-white hover:shadow-mirages max-[21rem]:h-42 md:h-62';
link.href = data.url;
link.style.setProperty('--card-background', cardBackground(data.url));
if (data.meta?.cover) {
const image = document.createElement('img');
image.className = 'absolute inset-0 size-full object-cover';
image.src = data.meta.cover;
image.alt = '';
image.loading = 'lazy';
link.append(image);
}
const mask = document.createElement('span');
mask.className = 'absolute inset-0 z-10 bg-black/25';
mask.setAttribute('aria-hidden', 'true');
link.append(mask);
const content = document.createElement('div');
content.className = 'absolute inset-0 z-20 flex flex-col items-center justify-center px-4 py-4 text-center md:px-6';
const title = document.createElement('h2');
title.className = 'm-0 max-w-[90%] [overflow-wrap:anywhere] font-sans text-[1.5625rem] leading-tight font-normal tracking-[0]';
title.textContent = data.meta?.title || data.url;
content.append(title);
const date = data.meta?.date;
const categories = data.meta?.categories ?? data.meta?.category;
const tags = data.meta?.tags;
if (date || categories || tags) {
const info = document.createElement('p');
info.className = 'mt-3 mb-0 max-w-[90%] [overflow-wrap:anywhere] text-[0.8125rem] leading-relaxed font-normal text-[#eee] [font-family:Consolas,Menlo,Monaco,"lucida_console","Liberation_Mono","Courier_New","andale_mono",monospaceX,monospace,sans-serif]';
const appendMeta = (value: string | undefined, className?: string) => {
if (!value) return;
if (info.childNodes.length > 0) info.append(' · ');
const item = document.createElement('span');
item.textContent = value;
if (className) item.className = className;
info.append(item);
};
appendMeta(date);
appendMeta(categories, '[font-family:var(--mirages-font-ui)]');
appendMeta(tags);
content.append(info);
}
link.append(content);
article.append(link);
results.append(article);
}
} catch { results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">搜索暂时不可用。</p>'; }
};
run();
</script>
+18
View File
@@ -0,0 +1,18 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import Masthead from '@/components/Masthead.astro';
import PostList from '@/components/PostList.astro';
import { deriveTaxonomy, getContentCacheKey, getPublicPosts, getTaxonomyPosts, POSTS_PER_PAGE, slugify } from '@/lib/content';
import { resolveTaxonomyBanner, tagBanners } from '@/category-banner.config';
export async function getStaticPaths() {
const posts = await getPublicPosts();
return deriveTaxonomy(posts, 'tags').map(({ name }) => { const tag = slugify(name); const entries = getTaxonomyPosts(posts, 'tags', tag); return { params: { tag }, props: { name, posts: entries }, cacheKey: getContentCacheKey(`tag:${tag}`, entries) }; });
}
const { name, posts } = Astro.props;
const total = Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE));
const banner = resolveTaxonomyBanner(tagBanners, slugify(name), { title: `#${name}`, subtitle: `带有“${name}”标签的文章。` });
---
<BaseLayout title={`标签:${name}`} description={`带有“${name}”标签的文章。`} navbarOverlay={Boolean(banner.image)}>
<Fragment slot="masthead"><Masthead banner={banner} textTone={banner.textTone} /></Fragment>
<PostList posts={posts.slice(0, POSTS_PER_PAGE)} total={total} basePath={`/tags/${slugify(name)}`} />
</BaseLayout>
+21
View File
@@ -0,0 +1,21 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import Masthead from '@/components/Masthead.astro';
import PostList from '@/components/PostList.astro';
import { deriveTaxonomy, getContentCacheKey, getPublicPosts, getTaxonomyPosts, POSTS_PER_PAGE, slugify } from '@/lib/content';
import { resolveTaxonomyBanner, tagBanners } from '@/category-banner.config';
export async function getStaticPaths() {
const posts = await getPublicPosts();
return deriveTaxonomy(posts, 'tags').flatMap(({ name }) => {
const entries = getTaxonomyPosts(posts, 'tags', slugify(name));
const total = Math.ceil(entries.length / POSTS_PER_PAGE);
return Array.from({ length: Math.max(0, total - 1) }, (_, index) => { const page = index + 2; const tag = slugify(name); return { params: { tag, page: String(page) }, props: { name, posts: entries.slice((page - 1) * POSTS_PER_PAGE, page * POSTS_PER_PAGE), page, total }, cacheKey: getContentCacheKey(`tag:${tag}:${page}`, entries) }; });
});
}
const { name, posts, page, total } = Astro.props;
const banner = resolveTaxonomyBanner(tagBanners, slugify(name), { title: `#${name}`, subtitle: `带有“${name}”标签的文章。` });
---
<BaseLayout title={`${name} - 第 ${page} 页`} navbarOverlay={Boolean(banner.image)}>
<Fragment slot="masthead"><Masthead banner={banner} textTone={banner.textTone} /></Fragment>
<PostList posts={posts} current={page} total={total} basePath={`/tags/${slugify(name)}`} />
</BaseLayout>
+71
View File
@@ -0,0 +1,71 @@
import type { SiteConfig } from './types/config';
export const siteConfig: SiteConfig = {
site: {
title: '路明笔记',
description: '一名高中生的技术与生活博客',
author: {
name: 'RiseForever',
avatar: 'https://weavatar.com/avatar/302380667bdaf4e1390800e62494d4af?s=512&r=G',
bio: '不慌张,不绝望,不狂妄,不投降。',
rotateAvatar: true
},
locale: 'zh-CN',
url: 'https://www.luming.cool',
favicon: 'https://image.luming.cool/i/2026/05/10/6a001b4129893.png'
},
navigation: [
{ label: '首页', href: '/', external: false },
{ label: '归档', href: '/archives/', external: false },
{ label: '测试页面', href: '/test-page/', external: false }
],
pjax: {
enabled: true
},
appearance: {
defaultTheme: 'auto',
accentColor: '#1abc9c'
},
banner: {
enabled: true,
title: '路明笔记',
subtitle: '一名高中生的技术与生活博客',
image: 'https://image.luming.cool/i/2026/07/26/6a6628967fe03.webp',
position: 'center center',
desktopHeightVh: 55,
mobileHeightVh: 40,
overlay: 0.25,
textTone: 'auto'
},
cards: {
defaultCovers: ['https://image.luming.cool/i/2026/08/03/6a6f79cc091ac.webp']
},
archives: {
tagLimit: 30
},
footer: {
copyright: `© 2023-${new Date().getFullYear()} RiseForever`,
links: [
{ label: 'BlogsClub', href: 'https://blogs.quest/luming', external: true },
{ label: '笔墨迹', href: 'https://blogscn.fun/blogs/01k7zk4mhndsr6atqd1wm11f7b', external: true },
{ label: '十年之约', href: 'https://www.foreverblog.cn/blog/6506.html', external: true },
{ label: '博友圈', href: 'https://www.boyouquan.com/blogs/luming.cool', external: true },
{ label: '好站网', href: 'https://haozhan.wang/site_detail.php?id=176', external: true },
{ label: '集博栈', href: 'https://www.heyblog.net/site/019eff04-b025-76c9-b8f2-388deb7195cd', external: true },
{ label: '博客大联盟', href: 'https://bo.ke/luming.cool/', external: true }
]
},
comments: {
provider: 'twikoo',
envId: 'https://twokii.luming.cool/',
region: 'cn',
lang: 'zh-CN'
},
search: { provider: 'pagefind', placeholder: '搜索文章' },
toolbarItems: [
{ type: 'search', icon: 'search', name: '搜索文章' },
{ type: 'rss', icon: 'rss', name: 'RSS 订阅', href: '/rss.xml' },
{ type: 'link', icon: 'tram-front', name: '开往', href: 'https://www.travellings.cn/plain.html', external: true },
{ type: 'settings', icon: 'settings', name: '阅读设置' }
]
};
+246
View File
@@ -0,0 +1,246 @@
.prose {
width: 100%;
color: var(--color-text);
font-family: var(--reading-font-family);
font-size: 1.0625rem;
line-height: 1.8;
}
@media (min-width: 1456px) and (max-width: 1919px) {
:root[data-platform='windows'] .prose { font-size: 1.125rem; }
}
@media (min-width: 1920px) {
.prose { font-size: 1.125rem; }
}
@media (min-width: 2400px) {
.prose { font-size: 1.125rem; }
}
:root[data-font='serif'] .prose {
font-size: 1.125rem;
line-height: 2;
}
@media (min-width: 1920px) {
:root[data-font='serif'] .prose { font-size: 1.1875rem; }
}
.prose :where(p, li, blockquote, td, th) { overflow-wrap: anywhere; }
.prose :where(h1, h2, h3, h4) {
margin: 1.8em 0 0.65em;
font-family: var(--reading-font-family);
font-weight: 600;
line-height: 1.25;
}
.about-page .prose > :first-child { margin-top: 0; }
.prose h1 {
overflow-wrap: anywhere;
font-size: clamp(1.875rem, 1.5rem + 1.5vw, 2.5rem);
line-height: 1.2;
text-wrap: balance;
}
.prose h2 {
font-size: 1.65rem;
}
.prose :where(p, ul, ol, blockquote, pre, table) {
margin-block: 1.15em;
}
.prose hr {
position: relative;
height: 1.25rem;
margin-block: 2.25em;
border: 0;
background:
linear-gradient(var(--color-border), var(--color-border)) left center / calc(50% - 0.75rem) 1px no-repeat,
linear-gradient(var(--color-border), var(--color-border)) right center / calc(50% - 0.75rem) 1px no-repeat;
}
.prose hr::after {
position: absolute;
top: 50%;
left: 50%;
width: 0.35rem;
height: 0.35rem;
border-radius: 50%;
background: var(--color-accent);
content: '';
transform: translate(-50%, -50%);
}
.prose :where(img, video) {
display: block;
max-width: 100%;
height: auto;
margin: 2em auto;
}
.prose blockquote {
margin-inline: 0;
padding: 0.2em 0 0.2em 1.2em;
border-left: 3px solid var(--color-accent);
color: var(--color-muted);
font-family: var(--reading-font-family);
font-size: 1.08em;
}
.prose :not(pre) > code {
padding: 0.2em 0.45em;
border-radius: var(--radius-sm);
background: var(--color-code);
font-family: var(--mirages-font-code);
font-size: .9em;
}
:root[data-theme='dark'] .prose :not(pre) > code {
background: #464242;
}
@media (prefers-color-scheme: dark) {
:root[data-theme='auto'] .prose :not(pre) > code {
background: #464242;
}
}
.prose pre {
max-width: 100%;
overflow-x: auto;
padding: 1rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--color-code);
color: var(--shiki-light, var(--color-text));
scrollbar-color: color-mix(in srgb, var(--color-muted) 60%, transparent) transparent;
scrollbar-width: thin;
}
.prose pre::-webkit-scrollbar { height: 8px; }
.prose pre::-webkit-scrollbar-thumb { border-radius: 4px; background: color-mix(in srgb, var(--color-muted) 60%, transparent); }
.prose pre::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--color-muted) 78%, transparent); }
.prose pre code {
display: block;
min-width: max-content;
font-family: var(--mirages-font-code);
font-size: .875rem;
line-height: 1.45;
}
.prose .astro-code code {
--code-line-digits: 1;
display: block;
padding-block: 1rem;
counter-reset: code-line;
font-size: 0;
}
.prose .astro-code code:has(.line:nth-child(10)) { --code-line-digits: 2; }
.prose .astro-code code:has(.line:nth-child(100)) { --code-line-digits: 3; }
.prose .astro-code code:has(.line:nth-child(1000)) { --code-line-digits: 4; }
.prose .astro-code .line {
display: flex;
min-height: 1.45em;
counter-increment: code-line;
font-size: .875rem;
line-height: 1.45;
}
.prose .astro-code .line::before {
display: inline-block;
width: calc(var(--code-line-digits) * 1ch + 2rem);
flex: 0 0 calc(var(--code-line-digits) * 1ch + 2rem);
margin-right: 1rem;
padding-inline: 1rem;
background: var(--color-code-gutter);
box-sizing: border-box;
color: var(--color-muted);
content: counter(code-line);
font-variant-numeric: tabular-nums;
opacity: 0.72;
text-align: right;
user-select: none;
}
.prose .astro-code .line:first-child::before {
box-shadow: 0 -1rem var(--color-code-gutter);
}
.prose .astro-code .line:last-child::before {
box-shadow: 0 1rem var(--color-code-gutter);
}
.prose .astro-code .line:only-child::before {
box-shadow: 0 -1rem var(--color-code-gutter), 0 1rem var(--color-code-gutter);
}
.prose .astro-code span {
color: var(--shiki-light);
}
:root[data-theme='dark'] .prose .astro-code {
background-color: var(--color-code);
color: var(--shiki-dark);
}
:root[data-theme='dark'] .prose .astro-code span {
color: var(--shiki-dark);
}
@media (prefers-color-scheme: dark) {
:root[data-theme='auto'] .prose .astro-code {
background-color: var(--color-code);
color: var(--shiki-dark);
}
:root[data-theme='auto'] .prose .astro-code span {
color: var(--shiki-dark);
}
}
.prose .astro-code {
padding: 0;
background: var(--color-code);
}
.prose .katex-display { max-width: 100%; overflow-x: auto; overflow-y: hidden; padding-block: .35em; }
.prose .mermaid { max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 1rem 0; }
.prose .mermaid svg { max-width: none; height: auto; }
.prose table {
width: 100%;
border-collapse: collapse;
}
.prose thead {
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface));
}
.prose tbody tr:nth-child(odd) {
background: color-mix(in srgb, var(--color-text) 4%, transparent);
}
.prose :where(th, td) img {
display: inline-block;
max-width: 100%;
height: auto;
margin: 0;
vertical-align: middle;
}
.prose :where(th, td) {
padding: 0.55em 0.8em;
border: 1px solid var(--color-border);
}
.prose :where(th, td)[align='left'] { text-align: left; }
.prose :where(th, td)[align='center'] { text-align: center; }
.prose :where(th, td)[align='right'] { text-align: right; }
+182
View File
@@ -0,0 +1,182 @@
@import 'tailwindcss/theme.css';
@import 'tailwindcss/utilities.css';
@import './tokens.css';
@import './themes.css';
@import './content.css';
@import './shortcodes.css';
@theme inline {
--color-mirages-accent: var(--color-accent);
--color-page: var(--color-bg);
--color-raised: var(--color-surface);
--color-foreground: var(--color-text);
--color-subtle: var(--color-muted);
--color-line: var(--color-border);
--color-code-surface: var(--color-code);
--color-nav: var(--nav-bg);
--font-sans: var(--reading-font-family);
--container-content: var(--content-width);
--container-page: var(--page-width);
--spacing-nav: var(--nav-height);
--shadow-mirages: var(--shadow-soft);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
min-height: 100%;
font-size: 100%;
scroll-behavior: smooth;
scrollbar-gutter: stable;
}
body {
margin: 0;
font-family: var(--reading-font-family);
line-height: 1.7;
}
:where(.prose h2, .prose h3, .prose h4) {
scroll-margin-top: calc(var(--nav-height) + 1rem);
}
:root[data-font='serif'] { --reading-font-family: var(--mirages-font-serif); }
:root[data-font='sans'] { --reading-font-family: var(--mirages-font-sans); }
body:has(dialog[open]) {
overflow: hidden;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
color: var(--color-accent-strong);
}
:where([data-text-link], .prose a:not(:has(img)), #comments a:not(:has(img))):not([data-no-text-link], [data-archive-entry], .pswp__button, .pswp__item, [role='button']) {
position: relative;
color: var(--color-accent);
}
:where([data-text-link], .prose a:not(:has(img)), #comments a:not(:has(img))):not([data-no-text-link], [data-archive-entry], .pswp__button, .pswp__item, [role='button'])::after {
position: absolute;
right: 0;
bottom: -0.08em;
left: 0;
border-bottom: 1px solid var(--color-accent);
content: '';
pointer-events: none;
transform: scaleX(0);
transform-origin: left center;
transition: transform 180ms ease-out;
}
:where([data-text-link], .prose a:not(:has(img)), #comments a:not(:has(img))):not([data-no-text-link], [data-archive-entry], .pswp__button, .pswp__item, [role='button']):is(:hover, :focus-visible)::after,
a:not([data-archive-entry]):is(:hover, :focus-visible) > [data-text-link]::after {
transform: scaleX(1);
}
button,
input,
select {
font-family: var(--mirages-font-ui);
font-size: inherit;
line-height: inherit;
}
[data-desktop-navbar] {
font-family: var(--mirages-font-ui);
}
[data-drawer],
[data-drawer] :where(button, input) {
font-family: var(--reading-font-family);
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}
::selection {
background: color-mix(in srgb, var(--color-accent) 35%, transparent);
}
@layer components {
.shell {
width: min(calc(100% - 2.5rem), var(--page-width));
margin-inline: auto;
}
.content-shell,
.card-list-shell {
width: min(calc(100% - 2.5rem), var(--content-width));
margin-inline: auto;
}
.article-shell {
width: min(calc(100% - 2.5rem), var(--article-content-width));
margin-inline: auto;
}
}
.skip-link {
position: fixed;
z-index: 100;
top: 10px;
left: 10px;
padding: 8px 12px;
border-radius: var(--radius-sm);
background: var(--color-text);
color: var(--color-bg);
transform: translateY(-150%);
}
.skip-link:focus {
transform: translateY(0);
}
@media (max-width: 600px) {
.shell,
.content-shell,
.card-list-shell {
width: min(calc(100% - 1.75rem), var(--page-width));
}
}
@media (max-width: 767px) {
html {
scrollbar-gutter: auto;
}
.article-shell {
width: min(calc(100% - 2.75rem), 666px);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
:where([data-text-link], .prose a:not(:has(img)), #comments a:not(:has(img)))::after {
transition: none;
}
}
+428
View File
@@ -0,0 +1,428 @@
.prose .shortcode-button {
display: inline-block;
margin: 0.2em 0.25em 0.2em 0;
padding: 0.35em 0.8em;
border: 1px solid var(--color-accent);
border-radius: var(--radius-sm);
background: var(--color-accent);
color: var(--color-bg);
font-family: var(--mirages-font-ui);
font-size: 0.9em;
}
.prose .shortcode-button:hover {
background: var(--color-accent-strong);
color: var(--color-bg);
}
.prose .shortcode-file {
background: transparent;
color: var(--color-accent);
}
.shortcode-tag {
display: inline-block;
margin: 0.15em 0.3em 0.15em 0;
padding: 0.08em 0.55em;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-bg);
font: 0.82em var(--mirages-font-ui);
}
.shortcode-tag-outline {
background: transparent;
color: var(--color-accent);
outline: 1px solid currentColor;
}
.shortcode-tag-success {
background: #287d5b;
}
.shortcode-tag-warning {
background: #a36b16;
}
.shortcode-tag-danger {
background: #ad3d48;
}
.shortcode-tag-info {
background: #35769b;
}
.shortcode-hint {
margin: 1.2em 0;
padding: 0.7em 1em;
border-left: 4px solid var(--color-accent);
background: var(--color-surface);
}
.shortcode-hint strong {
display: block;
margin-bottom: 0.2em;
font-family: var(--mirages-font-ui);
}
.shortcode-hint p {
margin: 0.25em 0;
}
.shortcode-hint-warning,
.shortcode-hint-warn {
border-color: #a36b16;
}
.shortcode-hint-error,
.shortcode-hint-danger {
border-color: #ad3d48;
}
.shortcode-hint-success {
border-color: #287d5b;
}
.shortcode-collapse {
margin: 1.2em 0;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.shortcode-collapse summary {
display: flex;
align-items: center;
cursor: pointer;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid transparent;
background: color-mix(in srgb, var(--color-accent) 7%, var(--color-surface));
color: var(--color-text);
font-family: var(--mirages-font-ui);
font-weight: 600;
list-style: none;
}
:root[data-font='serif'] .shortcode-collapse summary {
font-family: var(--mirages-font-serif);
}
.shortcode-collapse summary::-webkit-details-marker {
display: none;
}
.shortcode-collapse[open] summary {
border-bottom-color: var(--color-border);
}
.shortcode-collapse-marker {
width: 0.5rem;
height: 0.5rem;
flex: 0 0 0.5rem;
order: 2;
margin-left: auto;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(45deg) translate(-2px, -2px);
transition: transform 160ms ease;
}
.shortcode-collapse:not([data-collapse-ready])[open] > summary .shortcode-collapse-marker,
.shortcode-collapse.is-expanded > summary .shortcode-collapse-marker {
transform: rotate(-135deg) translate(-2px, -2px);
}
.shortcode-body {
box-sizing: border-box;
height: 0;
padding-inline: 1.5rem;
overflow: hidden;
transition: height 220ms ease;
}
.shortcode-collapse > .shortcode-body > .shortcode-body-inner {
display: block;
padding-block: 1rem;
padding-inline: 0;
}
.shortcode-collapse > .shortcode-body > .shortcode-body-inner > :first-child {
margin-top: 0;
}
.shortcode-collapse > .shortcode-body > .shortcode-body-inner > :last-child {
margin-bottom: 0;
}
@media (prefers-reduced-motion: reduce) {
.shortcode-body { transition: none; }
}
.shortcode-body-inner table {
width: 100%;
border-collapse: collapse;
}
.shortcode-body .shortcode-collapse {
margin: 1rem 0 0.25rem;
}
.shortcode-body .shortcode-collapse .shortcode-body-inner {
padding-inline: 0.85rem;
}
.shortcode-body .shortcode-collapse {
border-left: 2px solid
color-mix(in srgb, var(--color-accent) 45%, var(--color-border));
background: color-mix(in srgb, var(--color-bg) 35%, var(--color-surface));
}
.shortcode-body .shortcode-body .shortcode-collapse {
border-left-color: color-mix(
in srgb,
var(--color-accent) 25%,
var(--color-border)
);
}
.shortcode-tabs {
margin: 1.2em 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.shortcode-tab-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.35rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.shortcode-tab-list button {
cursor: pointer;
padding: 0.4rem 0.7rem;
border: 0;
background: transparent;
color: var(--color-muted);
}
.shortcode-tab-list button[aria-selected="true"] {
background: var(--color-bg);
color: var(--color-accent);
font-weight: 600;
}
.shortcode-tabs [role="tabpanel"] {
padding: 0.8rem 1rem;
background: var(--color-surface);
}
.shortcode-tools {
margin: 1.5em 0;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
font-family: var(--reading-font-family);
}
.shortcode-tools-title {
margin: 0 0 0.55rem;
color: var(--color-text);
font: 600 1rem/1.35 var(--reading-font-family);
}
.shortcode-tool-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem 1rem;
margin: 0;
padding: 0;
list-style: none;
}
.shortcode-tool {
min-width: 0;
}
.shortcode-tool-link {
display: flex;
min-width: 0;
min-height: 3.5rem;
align-items: center;
gap: 0.55rem;
padding: 0.4rem;
border-radius: var(--radius-sm);
color: var(--color-text);
text-decoration: none;
transition: background-color 160ms ease, color 160ms ease;
}
.shortcode-tool-link:hover {
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface));
color: var(--color-text);
}
.shortcode-tool-icon {
display: grid;
width: 2.5rem;
height: 2.5rem;
flex: 0 0 2.5rem;
place-items: center;
border-radius: 0.7rem;
background: var(--color-code);
}
:root[data-theme='dark'] .shortcode-tool-icon {
background: color-mix(in srgb, var(--color-bg) 88%, white);
}
@media (prefers-color-scheme: dark) {
:root[data-theme='auto'] .shortcode-tool-icon {
background: color-mix(in srgb, var(--color-bg) 88%, white);
}
}
.shortcode-tool-icon img {
display: block;
width: 72%;
height: 72%;
margin: 0;
object-fit: contain;
}
.shortcode-tool-content {
display: flex;
min-width: 0;
flex-direction: column;
justify-content: center;
gap: 0.08rem;
line-height: 1.25;
}
.shortcode-tool-name,
.shortcode-tool-description {
overflow-wrap: anywhere;
}
.shortcode-tool-name {
font-size: 0.9rem;
font-family: var(--reading-font-family);
line-height: 1.25;
}
.shortcode-tool-description {
color: var(--color-muted);
font-size: 0.78rem;
line-height: 1.3;
}
@media (max-width: 38rem) {
.shortcode-tools {
padding: 0.8rem;
}
.shortcode-tool-list {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.shortcode-tool-link { transition: none; }
}
.shortcode-github {
margin: 1.2em 0;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
padding: 0;
}
.shortcode-github-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem 0.7rem;
border-bottom: 1px solid var(--color-border);
}
.shortcode-github-title {
display: flex;
min-width: 0;
align-items: center;
gap: 0.55rem;
}
.shortcode-github-title h3 {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
font: 600 1.08rem/1.35 var(--mirages-font-ui);
}
.shortcode-github-icon {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
flex: 0 0 1.25rem;
color: var(--color-text);
}
.shortcode-github-owner,
.shortcode-github-repo {
color: var(--color-text);
text-decoration: none;
}
.shortcode-github-owner:hover,
.shortcode-github-repo:hover {
color: var(--color-accent);
}
.shortcode-github-repo {
color: var(--color-accent);
}
.shortcode-github-stats {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.8rem;
color: var(--color-muted);
font: 0.75rem var(--mirages-font-ui);
text-align: right;
}
.shortcode-github-stat {
white-space: nowrap;
}
.shortcode-github-stat b {
color: var(--color-text);
font-weight: 600;
}
.shortcode-github-stat-empty b {
color: var(--color-muted);
font-weight: 400;
}
.shortcode-github-content {
padding: 0.85rem 1rem 0.9rem;
}
.shortcode-github-description {
margin: 0;
color: var(--color-muted);
font-size: 0.92rem;
}
.shortcode-github-read-more {
display: inline-block;
margin-top: 0.7rem;
padding: 0.25rem 0.55rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-accent);
font: 0.78rem var(--mirages-font-ui);
text-decoration: none;
}
.shortcode-github-read-more:hover,
.shortcode-github-download:hover {
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-accent);
}
.shortcode-github-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 2.8rem;
padding: 0.6rem 1rem;
border-top: 1px solid var(--color-border);
color: var(--color-muted);
font: 0.75rem var(--mirages-font-ui);
}
.shortcode-github-commit {
min-width: 0;
overflow-wrap: anywhere;
}
.shortcode-github-commit-label {
color: var(--color-text);
}
.shortcode-github-date {
color: var(--color-muted);
}
.shortcode-github-download {
flex: 0 0 auto;
margin-left: auto;
padding: 0.25rem 0.55rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-accent);
text-decoration: none;
}
@media (max-width: 32rem) {
.shortcode-github-header,
.shortcode-github-footer {
align-items: flex-start;
flex-direction: column;
}
.shortcode-github-stats {
justify-content: flex-start;
text-align: left;
}
}
.shortcode-invalid {
color: var(--color-muted);
font-style: italic;
}
.shortcode-notice {
display: inline-block;
padding: 0.1em 0.45em;
border-left: 2px solid var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
color: var(--color-accent);
}
+103
View File
@@ -0,0 +1,103 @@
:root,
:root[data-theme='light'] {
color-scheme: light;
--color-bg: var(--mirages-page-light);
--color-surface: var(--mirages-page-light);
--color-text: var(--mirages-text-light);
--color-muted: #6e6a68;
--color-border: #dedbd8;
--color-code: #f0eeeb;
--color-code-gutter: #dedfdf;
--color-post-tag-bg: #eeeeee;
--color-archive-hover: #f0f0f0;
--heatmap-0: #ebedf0;
--heatmap-1: #9be9a8;
--heatmap-2: #40c463;
--heatmap-3: #30a14e;
--heatmap-4: #216e39;
--nav-bg: rgb(255 255 255 / 78%);
}
:root[data-theme='sunset'] {
color-scheme: light;
--color-bg: var(--mirages-page-sunset);
--color-surface: #fffaf0;
--color-text: #3f3832;
--color-muted: #786d63;
--color-border: #ded1c0;
--color-code: #eee3d3;
--color-code-gutter: #d4c8b8;
--color-post-tag-bg: #e9ddcc;
--color-archive-hover: var(--color-code);
--heatmap-0: #e9dfd0;
--heatmap-1: #9be9a8;
--heatmap-2: #40c463;
--heatmap-3: #30a14e;
--heatmap-4: #216e39;
--nav-bg: rgb(248 241 228 / 80%);
}
:root[data-theme='dark'] {
color-scheme: dark;
--color-bg: var(--mirages-page-dark);
--color-surface: var(--mirages-raised-dark);
--color-text: #eeece8;
--color-muted: #aaa5a0;
--color-border: #504c4b;
--color-code: #242222;
--color-code-gutter: #3c3939;
--color-post-tag-bg: #3c3939;
--color-archive-hover: var(--color-surface);
--heatmap-0: #454242;
--heatmap-1: #0e4429;
--heatmap-2: #006d32;
--heatmap-3: #26a641;
--heatmap-4: #39d353;
--nav-bg: rgb(44 42 42 / 80%);
--color-accent-strong: #5bddc3;
--shadow-soft: 0 14px 40px rgb(0 0 0 / 20%);
}
@media (prefers-color-scheme: dark) {
:root[data-theme='auto'] {
color-scheme: dark;
--color-bg: var(--mirages-page-dark);
--color-surface: var(--mirages-raised-dark);
--color-text: #eeece8;
--color-muted: #aaa5a0;
--color-border: #504c4b;
--color-code: #242222;
--color-code-gutter: #3c3939;
--color-post-tag-bg: #3c3939;
--color-archive-hover: var(--color-surface);
--heatmap-0: #454242;
--heatmap-1: #0e4429;
--heatmap-2: #006d32;
--heatmap-3: #26a641;
--heatmap-4: #39d353;
--nav-bg: rgb(44 42 42 / 80%);
--color-accent-strong: #5bddc3;
--shadow-soft: 0 14px 40px rgb(0 0 0 / 20%);
}
}
@media (prefers-color-scheme: light) {
:root[data-theme='auto'] {
color-scheme: light;
--color-bg: var(--mirages-page-light);
--color-surface: var(--mirages-page-light);
--color-text: var(--mirages-text-light);
--color-muted: #6e6a68;
--color-border: #dedbd8;
--color-code: #f0eeeb;
--color-code-gutter: #dedfdf;
--color-post-tag-bg: #eeeeee;
--color-archive-hover: #f0f0f0;
--heatmap-0: #ebedf0;
--heatmap-1: #9be9a8;
--heatmap-2: #40c463;
--heatmap-3: #30a14e;
--heatmap-4: #216e39;
--nav-bg: rgb(255 255 255 / 78%);
}
}
+90
View File
@@ -0,0 +1,90 @@
:root {
--mirages-accent: #1abc9c;
--mirages-page-light: #fff;
--mirages-page-sunset: #f8f1e4;
--mirages-page-dark: #2c2a2a;
--mirages-raised-dark: #343232;
--mirages-text-light: #202020;
--color-accent: var(--mirages-accent);
--color-accent-strong: #138a74;
--color-accent-contrast: #102b27;
--mirages-font-ui: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mirages-font-sans: "Mirages Custom", Merriweather, "Open Sans", "PingFang SC", "Noto Sans SC", "Noto Sans TC", "Microsoft YaHei", "WenQuanYi Micro Hei", "Segoe UI Emoji", "Segoe UI Symbol", Helvetica, Arial, sans-serif;
--mirages-font-serif: "Noto Serif SC", serif;
--mirages-font-code: Consolas, Menlo, Monaco, "Liberation Mono", "Courier New", monospace;
--font-sans: var(--mirages-font-sans);
--font-serif: var(--mirages-font-serif);
--reading-font-family: var(--mirages-font-sans);
--content-width: 704px;
--article-content-width: 628px;
--page-width: 720px;
--nav-height: 4rem;
--radius-sm: .25rem;
--radius-md: .5rem;
--shadow-soft: 0 14px 40px rgb(20 25 24 / 9%);
--transition-fast: 160ms ease;
}
:root[data-font='serif'] {
--article-content-width: 684px;
}
@media (min-width: 1251px) {
:root {
--content-width: 848px;
--article-content-width: 684px;
--page-width: 864px;
}
:root[data-font='serif'] {
--article-content-width: 742px;
}
}
@media (min-width: 1600px) {
:root {
--content-width: 880px;
--article-content-width: 742px;
--page-width: 896px;
}
:root[data-font='serif'] {
--article-content-width: 802px;
}
}
@media (min-width: 1800px) {
:root {
--content-width: 944px;
--article-content-width: 774px;
--page-width: 960px;
}
:root[data-font='serif'] {
--article-content-width: 842px;
}
}
@media (min-width: 1920px) {
:root {
--content-width: 976px;
--article-content-width: 822px;
--page-width: 992px;
}
:root[data-font='serif'] {
--article-content-width: 902px;
}
}
@media (min-width: 2400px) {
:root {
--content-width: 1008px;
--article-content-width: 872px;
--page-width: 1024px;
}
:root[data-font='serif'] {
--article-content-width: 962px;
}
}
+74
View File
@@ -0,0 +1,74 @@
export type ThemeMode = 'auto' | 'light' | 'sunset' | 'dark';
export type BannerTextTone = 'light' | 'dark' | 'auto';
export interface LinkItem {
label: string;
href: string;
external: boolean;
}
type CommentsConfig =
| { provider: 'none' }
| { provider: 'twikoo'; envId: string; region?: 'cn' | 'ap' | 'us'; lang?: string; path?: string };
type SearchConfig =
| { provider: 'none' }
| { provider: 'pagefind'; placeholder: string };
export type ToolbarIcon = 'search' | 'rss' | 'settings' | 'link' | 'tram-front';
export type ToolbarItem =
| { type: 'search'; icon: 'search'; name: string }
| { type: 'settings'; icon: 'settings'; name: string }
| { type: 'rss'; icon: 'rss'; name: string; href: string; external?: boolean }
| { type: 'link'; icon: ToolbarIcon; name: string; href: string; external?: boolean };
export interface SiteConfig {
site: {
title: string;
description: string;
author: {
name: string;
avatar: string;
bio: string;
rotateAvatar?: boolean;
};
locale: string;
url: string;
favicon: string;
};
navigation: LinkItem[];
appearance: {
defaultTheme: ThemeMode;
accentColor: `#${string}`;
};
banner: {
enabled: boolean;
title: string;
subtitle: string;
image?: string;
position: string;
desktopHeightVh: number;
mobileHeightVh: number;
overlay: number;
textTone?: BannerTextTone;
};
cards: {
defaultCovers: string[];
};
archives: {
tagLimit: number;
};
footer: {
copyright?: string;
note?: string;
theme?: LinkItem;
links: LinkItem[];
};
comments: CommentsConfig;
search: SearchConfig;
pjax: {
enabled: boolean;
};
toolbarItems: ToolbarItem[];
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}