内容警告:大部分代码参考自Copilot。
astro 有自己的 icon 插件。
如果要方便地使用的话可以用iconify。
这个网站就使用了MDI和Memory(像素风)
需要安装gray-matter
和reading-time
以处理 Front Matter 和计算阅读时间。
用的是 Starlight 所以 DOCS_DIR 设置为了默认目录。
它会自动插入在最后的---
之前
#!/usr/bin/env nodeimport fs from 'node:fs';import matter from 'gray-matter';import readingTime from 'reading-time';import glob from 'fast-glob';
const DOCS_DIR = 'src/content/docs';const files = glob.sync(`${DOCS_DIR}/**/*.{md,mdx}`, { dot: false });
files.forEach((file) => { const raw = fs.readFileSync(file, 'utf8'); const { data: fm, content } = matter(raw, { preserveWhitespace: true });
// 文件时间(仅首次创建用 birthtime) const stats = fs.statSync(file); const createdAt = fm.createdAt || stats.birthtime.toISOString();
// 字数与阅读时间 const statsRead = readingTime(content, { locale: 'zh-CN', wordsPerMinute: 220, round: 'ceil', minMinutes: 1 });
// 保留旧 updatedAt,除非字段变化 let updatedAt = fm.updatedAt; const oldWords = fm.words; const oldReadText = fm.readingTimeText;
const words = statsRead.words; const readingTimeText = statsRead.text;
const contentChanged = words !== oldWords || readingTimeText !== oldReadText;
if (contentChanged) { updatedAt = new Date().toISOString(); }
// ====== 核心:更新 frontmatter 字段 ====== const insertOrUpdateAtEnd = (rawText, key, value) => { const yamlLine = `${key}: ${typeof value === 'string' ? JSON.stringify(value) : value}`; const firstSep = rawText.indexOf('---'); const secondSep = rawText.indexOf('---', firstSep + 3); if (firstSep === -1 || secondSep === -1) return rawText; // 无 frontmatter
const fmBlock = rawText.slice(0, secondSep).trimEnd(); const body = rawText.slice(secondSep);
const regex = new RegExp(`^${key}:.*$`, 'm'); let newFmBlock; if (regex.test(fmBlock)) { newFmBlock = fmBlock.replace(regex, yamlLine); } else { newFmBlock = fmBlock + `\n${yamlLine}`; } return newFmBlock + '\n' + body; };
let newRaw = raw; newRaw = insertOrUpdateAtEnd(newRaw, 'createdAt', createdAt); newRaw = insertOrUpdateAtEnd(newRaw, 'updatedAt', updatedAt); newRaw = insertOrUpdateAtEnd(newRaw, 'words', words); newRaw = insertOrUpdateAtEnd(newRaw, 'readingTimeText', readingTimeText);
// ====== 关键判断:无变动则不写文件 ====== if (newRaw !== raw) { fs.writeFileSync(file, newRaw, 'utf8'); console.log(`[inject-frontmatter-meta] ${file} → ${createdAt} / ${updatedAt} / ${words} / ${readingTimeText}`); }});
console.log(`✅ 处理完成,共检测 ${files.length} 个文件`);
为了 Front Matter 有效,需要在content.config.ts
为 docsSchema 添加如下字段:
createdAt: z.string().optional(), updatedAt: z.string().optional(), words: z.number().optional(), readingTimeText: z.string().optional(),
最后在组件里声明并引用就行了。
Astro 有自带的自定义路由功能。多语言版也是基于这个写的(虽然暂时没翻译工作量太大了之后再说)
icon 的设定和部分静态文本是用json
储存的。但类似于 “* 发” 这样的虽然对键做了匹配,但是 json 的值不能动态所以切换为了 TypeScript 以定义 introsMap。用
`"*发": (m) => `发色为${m}发的活动。`
就可以显示出动态的介绍。
Starlight 内置了i18n 支持
Astro 也有i18n 支持
在时间线页面用到的组件。本质上是一个容器。
虽然写了点补救的 css 但本质上还是用的 tailwind 的类名,如果你也要用的话自行酌情修改(
用法:新建一个Fitler.astro
并在CustomPageFrame.astro
导入并引用以覆盖完整页面(在文章内用…… 只能覆盖文章本身)
---export interface Props { activeMode: 'crt' | 'pixel' | 'none' ;}//对应模式的类别const { activeMode } = Astro.props;const crtFilterId = 'crt-color-map';---
<style> .filter-svg-container { position: absolute; width: 0; height: 0; overflow: hidden; }
/* crt的闪烁动画,防止太瞎眼我改低了 */ @keyframes crt-flicker { 0% { opacity: 0.98; } 50% { opacity: 1; } 100% { opacity: 0.98; } }
/* crt模式,时间线页面用的就是这个的无闪烁动画版 */ .filter-overlay[data-active-mode="crt"] { filter: url(#{crtFilterId}); background-image: linear-gradient(to bottom, transparent 50%, rgba(0, 0, 0, 0.3) 51%), linear-gradient(to right, transparent 50%, rgba(0, 0, 0, 0.3) 51%); background-size: 100% 2px, 2px 100%; animation: crt-flicker 1s infinite; }
.filter-overlay[data-active-mode="crt"]::before { content: ""; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(ellipse at 60% center, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.9) 100%); }
/* 聊胜于无的像素模式 */ .filter-overlay[data-active-mode="pixel"] { background-image: linear-gradient(to right, var(--sl-color-black) 1px, transparent 1px), linear-gradient(to bottom, var(--sl-color-black) 1px, transparent 1px); background-size: 3px 3px; opacity:0.6; }</style>
<div class="filter-svg-container"> <svg xmlns="http://www.w3.org/2000/svg"> <filter id={crtFilterId}> <feComponentTransfer> <feFuncR type="linear" slope="1.2" intercept="0"/> <feFuncG type="linear" slope="1.2" intercept="0"/> <feFuncB type="table" tableValues="0.8 0.9 1 0.9 0.7 0.5 0.3 0.1 0 0 0 0 0 0 0"/> </feComponentTransfer> </filter> </svg></div>
<div class="filter-overlay fixed top-0 left-0 w-full h-full pointer-events-none opacity-100 transition-opacity duration-300 ease-in-out z-100" data-active-mode={activeMode}></div>
没啥好写的,做完感觉还得修 本质上就是个点击折叠容器,虽然设计的时候感觉这肯定很模扰吧写出来却又微妙的不太能感觉到世界观了,可能是因为蓝色的部分太少了吧……
Raw bytes: E8 AD A6 E5 91 8A EF BC 9A E6 96 87 E6 9C AC E7 BC 96 E7 A0 81 E5 A4 B1 E8 B4 A5
用 markdown 也是可以的
这部分的设置看起来像
<Warning id="section-2" client:load message="Raw bytes: E8 AD A6 E5 91 8A EF BC 9A E6 96 87 E6 9C AC E7 BC 96 E7 A0 81 E5 A4 B1 E8 B4 A5" buttonText="我ç�†è§£äº†ï¼Œç»§ç»" icon = 'tdesign:joyful-filled'></Warning>
import { useEffect, useState } from 'react';import { Icon } from '@iconify/react';
interface WarningProps { children: React.ReactNode; id: string; message?: string; buttonText?: string; icon?: string; className?: string; textClass?: string; buttonClass?: string; iconClass?: string;}
export default function Warning({ children, id, message = '以下内容可能包含令人不适的信息。', buttonText = '我知道了', icon = 'mdi:alert', className = 'bg-transparent', textClass = 'text-center text-[rgba(var(--sl-color-gray-7-rgb),1)] dark:text-[rgba(var(--sl-color-white-rgb),1)] bg-[rgba(var(--sl-color-accent-low-rgb),0.22)] dark:bg-[rgba(var(--sl-color-accent-low-rgb),0.35)]', buttonClass = 'px-3 py-1 text-sm rounded-md text-white bg-[rgba(var(--sl-color-accent-rgb),1)] hover:bg-[rgba(var(--sl-color-accent-high-rgb),1)] transition-colors', iconClass = 'text-[var(--sl-color-accent)] w-[3em] h-[3em]'}: WarningProps) { const [ack, setAck] = useState(false); const [reveal, setReveal] = useState(false);
useEffect(() => { const stored = localStorage.getItem(`warning-${id}`); if (stored === 'true') { setAck(true); // 立即显示,无需过渡 setReveal(true); } }, [id]);
useEffect(() => { if (ack) { // 切到可见后再开启淡入 const raf = requestAnimationFrame(() => setReveal(true)); return () => cancelAnimationFrame(raf); } }, [ack]);
const handleConfirm = () => { localStorage.setItem(`warning-${id}`, 'true'); setAck(true); };
return ( <div className="my-6"> {!ack && ( <div className={`flex flex-col items-center justify-center gap-3 p-3 ${className}`}> <div className="flex items-center justify-center gap-2 flex-wrap"> {icon && <Icon icon={icon} className={iconClass} />} <p className={`font-sans-bold leading-relaxed p-2 ${textClass}`}>{message}</p> </div> <div className="flex justify-center w-full"> <button onClick={handleConfirm} className={buttonClass}> {buttonText} </button> </div> </div> )}
{/* 未确认前:完全从文档流移除;确认后:进入文档流并淡入 */} <div className={ack ? 'block' : 'hidden'}> <div className={`transition-opacity transform duration-500 ease-out ${ reveal ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2' }`} > {children} </div> </div> </div> );}