大 更 新
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
@@ -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>}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
@@ -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/)
|
||||
@@ -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]: 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。
|
||||
Vendored
+12
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 ?? ''));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)}`);
|
||||
@@ -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)
|
||||
}))
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: '阅读设置' }
|
||||
]
|
||||
};
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user