@mintfolio/theme-verdant 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +66 -0
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/assets/favicon.ico +0 -0
- package/assets/favicon.svg +9 -0
- package/assets/fonts/Inter-300.woff2 +0 -0
- package/assets/fonts/Inter-400.woff2 +0 -0
- package/assets/fonts/Inter-500.woff2 +0 -0
- package/assets/fonts/Inter-600.woff2 +0 -0
- package/assets/fonts/Inter-700.woff2 +0 -0
- package/assets/fonts/JetBrainsMono-400.woff2 +0 -0
- package/assets/fonts/JetBrainsMono-500.woff2 +0 -0
- package/assets/fonts/PlayfairDisplay-400.woff2 +0 -0
- package/assets/fonts/PlayfairDisplay-500.woff2 +0 -0
- package/assets/fonts/PlayfairDisplay-600.woff2 +0 -0
- package/assets/fonts/PlayfairDisplay-700.woff2 +0 -0
- package/components/base/Contact.astro +173 -0
- package/components/base/Hero.astro +228 -0
- package/components/base/Navigation.astro +288 -0
- package/components/base/Projects.astro +207 -0
- package/components/base/Skills.astro +158 -0
- package/components/blog/ArticleProse.astro +217 -0
- package/components/blog/CodeBlockEnhancer.astro +310 -0
- package/components/blog/PostCard.astro +98 -0
- package/components/blog/ProtectedArticle.astro +89 -0
- package/config/theme-verdant.config.mjs +74 -0
- package/layouts/Base.astro +460 -0
- package/lib/palettes.ts +10 -0
- package/licenses/Inter-OFL.txt +92 -0
- package/licenses/JetBrainsMono-OFL.txt +93 -0
- package/licenses/PlayfairDisplay-OFL.txt +191 -0
- package/package.json +68 -0
- package/pages/archive.astro +384 -0
- package/pages/home.astro +2049 -0
- package/pages/page.astro +434 -0
- package/pages/post.astro +1029 -0
- package/scripts/article.ts +42 -0
- package/scripts/articleOrigin.ts +31 -0
- package/scripts/articleTables.ts +15 -0
- package/scripts/blog.ts +26 -0
- package/scripts/codeBlocks.ts +172 -0
- package/scripts/hero.ts +173 -0
- package/scripts/home.ts +12 -0
- package/scripts/homePanels.ts +100 -0
- package/scripts/hotContent.ts +16 -0
- package/scripts/lifecycle.ts +3 -0
- package/scripts/lightbox.ts +14 -0
- package/scripts/postFilters.ts +101 -0
- package/scripts/postList.ts +132 -0
- package/scripts/progress.ts +29 -0
- package/scripts/protectedArticle.ts +49 -0
- package/scripts/protectedToc.ts +29 -0
- package/scripts/site.ts +145 -0
- package/scripts/tagFold.ts +61 -0
- package/scripts/theme.ts +102 -0
- package/scripts/toc.ts +149 -0
- package/settings.ts +21 -0
- package/styles/fonts.css +120 -0
- package/styles/global.css +146 -0
- package/styles/themes/happyhues/effects.css +62 -0
- package/styles/themes/happyhues/index.css +2 -0
- package/styles/themes/happyhues/theme.css +225 -0
- package/theme.mjs +60 -0
- package/utils/blogNavigation.ts +3 -0
- package/utils/homeReturnHint.ts +34 -0
- package/utils/storage.ts +16 -0
- package/utils/types.ts +8 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { PageScope } from './lifecycle';
|
|
2
|
+
import { emptyFilters } from './postList';
|
|
3
|
+
import type { PostFilters, PostItem } from '../utils/types';
|
|
4
|
+
|
|
5
|
+
import { createPostListController, readFiltersFromUrl as readUrlFilters, writeFiltersToUrl as writeUrlFilters } from '@mintfolio/core/client';
|
|
6
|
+
|
|
7
|
+
/** Read the current document's query state through the same parser used by Core. */
|
|
8
|
+
export function readFiltersFromUrl(): PostFilters {
|
|
9
|
+
return readUrlFilters(new URL(window.location.href));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The caller supplies a Core-owned destination; the SDK only updates filter parameters. */
|
|
13
|
+
export function writeFiltersToUrl(filters: PostFilters, url = new URL(window.location.href)): URL {
|
|
14
|
+
return writeUrlFilters(filters, url);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createPostFilters(
|
|
18
|
+
items: PostItem[],
|
|
19
|
+
input: HTMLInputElement | null,
|
|
20
|
+
scope: PageScope,
|
|
21
|
+
onChange: (filters: PostFilters) => void,
|
|
22
|
+
initial: PostFilters = emptyFilters(),
|
|
23
|
+
) {
|
|
24
|
+
const tagButtons = Array.from(document.querySelectorAll<HTMLButtonElement>('.tag-btn'));
|
|
25
|
+
const categoryButtons = Array.from(document.querySelectorAll<HTMLButtonElement>('.category-btn'));
|
|
26
|
+
const state = { ...initial };
|
|
27
|
+
const includes = (buttons: HTMLButtonElement[], key: 'tag' | 'category', value: string): boolean =>
|
|
28
|
+
buttons.some((button) => button.dataset[key] === value);
|
|
29
|
+
if (!includes(tagButtons, 'tag', state.tag)) state.tag = '';
|
|
30
|
+
if (!includes(categoryButtons, 'category', state.category)) state.category = '';
|
|
31
|
+
if (input) input.value = state.q;
|
|
32
|
+
|
|
33
|
+
const controller = createPostListController({ items, index: (item) => item, initialFilters: state, reconcileFacets: true });
|
|
34
|
+
scope.add(controller.dispose);
|
|
35
|
+
scope.add(controller.subscribe((snapshot) => {
|
|
36
|
+
Object.assign(state, snapshot.filters);
|
|
37
|
+
const tags = new Set(snapshot.facets.tags);
|
|
38
|
+
const categories = new Set(snapshot.facets.categories);
|
|
39
|
+
const updateButtons = (
|
|
40
|
+
buttons: HTMLButtonElement[],
|
|
41
|
+
key: 'tag' | 'category',
|
|
42
|
+
available: Set<string>,
|
|
43
|
+
): void => {
|
|
44
|
+
for (const button of buttons) {
|
|
45
|
+
const value = button.dataset[key] ?? '';
|
|
46
|
+
const selected = value === state[key];
|
|
47
|
+
button.disabled = Boolean(value) && !available.has(value.toLowerCase());
|
|
48
|
+
button.classList.toggle('hidden-by-filter', button.disabled);
|
|
49
|
+
button.classList.toggle('active', selected);
|
|
50
|
+
button.setAttribute('aria-checked', String(selected));
|
|
51
|
+
button.tabIndex = selected ? 0 : -1;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
updateButtons(tagButtons, 'tag', tags);
|
|
55
|
+
updateButtons(categoryButtons, 'category', categories);
|
|
56
|
+
onChange({ ...state });
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const bindGroup = (buttons: HTMLButtonElement[], key: 'tag' | 'category'): void => {
|
|
60
|
+
for (const button of buttons) {
|
|
61
|
+
button.addEventListener(
|
|
62
|
+
'click',
|
|
63
|
+
() => {
|
|
64
|
+
controller.setFilters({ [key]: button.dataset[key] ?? '' });
|
|
65
|
+
},
|
|
66
|
+
{ signal: scope.signal },
|
|
67
|
+
);
|
|
68
|
+
button.addEventListener(
|
|
69
|
+
'keydown',
|
|
70
|
+
(event) => {
|
|
71
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
const visible = buttons.filter((candidate) => !candidate.disabled);
|
|
74
|
+
const direction = ['ArrowLeft', 'ArrowUp'].includes(event.key) ? -1 : 1;
|
|
75
|
+
const index =
|
|
76
|
+
event.key === 'Home'
|
|
77
|
+
? 0
|
|
78
|
+
: event.key === 'End'
|
|
79
|
+
? visible.length - 1
|
|
80
|
+
: (visible.indexOf(button) + direction + visible.length) % visible.length;
|
|
81
|
+
const next = visible[index];
|
|
82
|
+
if (next) {
|
|
83
|
+
next.click();
|
|
84
|
+
next.focus();
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{ signal: scope.signal },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
bindGroup(tagButtons, 'tag');
|
|
92
|
+
bindGroup(categoryButtons, 'category');
|
|
93
|
+
input?.addEventListener(
|
|
94
|
+
'input',
|
|
95
|
+
() => {
|
|
96
|
+
controller.setFilters({ q: input.value });
|
|
97
|
+
},
|
|
98
|
+
{ signal: scope.signal },
|
|
99
|
+
);
|
|
100
|
+
return { value: (): PostFilters => ({ ...state }) };
|
|
101
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { PageScope } from './lifecycle';
|
|
2
|
+
import type { PostFilters, PostItem } from '../utils/types';
|
|
3
|
+
|
|
4
|
+
import { createPostListController, searchPosts } from '@mintfolio/core/client';
|
|
5
|
+
export { emptyFilters } from '@mintfolio/core/client';
|
|
6
|
+
|
|
7
|
+
interface PostListSnapshot {
|
|
8
|
+
href: string;
|
|
9
|
+
limit: number;
|
|
10
|
+
autoLoadEnabled: boolean;
|
|
11
|
+
filters: PostFilters;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const stateKey = 'personalSitePostList';
|
|
15
|
+
|
|
16
|
+
function readSnapshot(url: URL): PostListSnapshot | null {
|
|
17
|
+
const value: Partial<PostListSnapshot> | undefined = history.state?.[stateKey];
|
|
18
|
+
if (value?.href !== `${url.pathname}${url.search}` || typeof value.limit !== 'number' || !Number.isInteger(value.limit) || value.limit < 1) return null;
|
|
19
|
+
if (!value.filters || !['q', 'tag', 'category'].every((key) => typeof value.filters?.[key as keyof PostFilters] === 'string')) return null;
|
|
20
|
+
return { href: value.href, limit: value.limit, autoLoadEnabled: value.autoLoadEnabled === true, filters: value.filters };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Restore layout before Astro restores scroll, otherwise the short initial list clamps it. */
|
|
24
|
+
function restoreListDocument(root: Document, url: URL, snapshot: PostListSnapshot | null): void {
|
|
25
|
+
const hero = root.querySelector<HTMLElement>('#hero');
|
|
26
|
+
if (hero && url.hash === '#content') {
|
|
27
|
+
hero.classList.add('collapsed');
|
|
28
|
+
hero.inert = true;
|
|
29
|
+
root.querySelector('.content-wrapper')?.classList.add('hero-collapsed');
|
|
30
|
+
}
|
|
31
|
+
if (!snapshot || !root.getElementById('posts-list')) return;
|
|
32
|
+
const items = readPostItems(root.getElementById('posts-list')!);
|
|
33
|
+
const matches = filterPostItems(items, snapshot.filters);
|
|
34
|
+
const visible = new Set(matches.slice(0, snapshot.limit));
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
item.element.classList.toggle('hidden', !visible.has(item));
|
|
37
|
+
item.element.classList.remove('initially-hidden');
|
|
38
|
+
}
|
|
39
|
+
root.getElementById('load-more-wrap')?.classList.toggle('hidden', matches.length <= snapshot.limit);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
document.addEventListener('astro:before-preparation', () => {
|
|
43
|
+
// Stop an article's smooth section scroll before its queued frames can move the next page.
|
|
44
|
+
window.scrollTo({ left: window.scrollX, top: window.scrollY, behavior: 'instant' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
document.addEventListener('astro:before-swap', (event) => {
|
|
48
|
+
// Astro restores history coordinates before page-load; CSS smooth scrolling can interrupt that restoration.
|
|
49
|
+
if (event.navigationType === 'traverse') event.newDocument.documentElement.dataset.restoringScroll = 'true';
|
|
50
|
+
restoreListDocument(event.newDocument, event.to, event.navigationType === 'traverse' ? readSnapshot(event.to) : null);
|
|
51
|
+
});
|
|
52
|
+
document.addEventListener('astro:page-load', () => { delete document.documentElement.dataset.restoringScroll; });
|
|
53
|
+
restoreListDocument(document, new URL(location.href), readSnapshot(new URL(location.href)));
|
|
54
|
+
|
|
55
|
+
export function readPostItems(root: ParentNode): PostItem[] {
|
|
56
|
+
return Array.from(root.querySelectorAll<HTMLElement>('.post-card'), (element) => ({
|
|
57
|
+
element,
|
|
58
|
+
id: element.dataset.id ?? '',
|
|
59
|
+
url: element.dataset.url ?? '',
|
|
60
|
+
title: element.dataset.title ?? '',
|
|
61
|
+
description: element.dataset.description ?? '',
|
|
62
|
+
tags: JSON.parse(element.dataset.tags ?? '[]') as string[],
|
|
63
|
+
category: element.dataset.category ?? '',
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function filterPostItems(posts: PostItem[], filters: PostFilters): PostItem[] {
|
|
68
|
+
return searchPosts(posts, filters);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A single observer and DOM index serve both the home and blog lists. */
|
|
72
|
+
export function createPostList(scope: PageScope) {
|
|
73
|
+
const container = document.getElementById('posts-list');
|
|
74
|
+
const button = document.getElementById('load-more-btn');
|
|
75
|
+
const footer = document.getElementById('load-more-wrap');
|
|
76
|
+
const noResults = document.getElementById('no-results');
|
|
77
|
+
const items = readPostItems(container ?? document);
|
|
78
|
+
const pageSize = Number(container?.dataset.pageSize) || 8;
|
|
79
|
+
const saved = readSnapshot(new URL(location.href));
|
|
80
|
+
const controller = createPostListController({ items, index: (item) => item, pageSize, initialFilters: saved?.filters, initialLimit: saved?.limit });
|
|
81
|
+
scope.add(controller.dispose);
|
|
82
|
+
let autoLoadEnabled = saved?.autoLoadEnabled ?? false;
|
|
83
|
+
let leftViewport = false;
|
|
84
|
+
|
|
85
|
+
const render = (): void => {
|
|
86
|
+
const { matches, visible: shown, limit, filters } = controller.value();
|
|
87
|
+
const visible = new Set(shown);
|
|
88
|
+
for (const item of items) {
|
|
89
|
+
item.element.classList.toggle('hidden', !visible.has(item));
|
|
90
|
+
item.element.classList.remove('initially-hidden');
|
|
91
|
+
}
|
|
92
|
+
if (noResults) noResults.style.display = matches.length ? 'none' : 'block';
|
|
93
|
+
footer?.classList.toggle('hidden', matches.length <= limit);
|
|
94
|
+
const snapshot: PostListSnapshot = { href: `${location.pathname}${location.search}`, limit, autoLoadEnabled, filters };
|
|
95
|
+
history.replaceState({ ...history.state, [stateKey]: snapshot }, '');
|
|
96
|
+
};
|
|
97
|
+
const loadMore = (): void => {
|
|
98
|
+
leftViewport = false;
|
|
99
|
+
controller.loadMore();
|
|
100
|
+
};
|
|
101
|
+
button?.addEventListener(
|
|
102
|
+
'click',
|
|
103
|
+
() => {
|
|
104
|
+
autoLoadEnabled = true;
|
|
105
|
+
loadMore();
|
|
106
|
+
},
|
|
107
|
+
{ signal: scope.signal },
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (footer) {
|
|
111
|
+
const observer = new IntersectionObserver(
|
|
112
|
+
([entry]) => {
|
|
113
|
+
if (!entry) return;
|
|
114
|
+
if (!entry.isIntersecting) leftViewport = true;
|
|
115
|
+
else if (autoLoadEnabled && leftViewport && controller.value().hasMore) loadMore();
|
|
116
|
+
},
|
|
117
|
+
{ rootMargin: '0px 0px 160px 0px', threshold: 0.15 },
|
|
118
|
+
);
|
|
119
|
+
observer.observe(footer);
|
|
120
|
+
scope.add(() => observer.disconnect());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
scope.add(controller.subscribe(render));
|
|
124
|
+
return {
|
|
125
|
+
items,
|
|
126
|
+
filter(nextFilters: PostFilters): void {
|
|
127
|
+
const current = controller.value().filters;
|
|
128
|
+
if (current.q !== nextFilters.q || current.tag !== nextFilters.tag || current.category !== nextFilters.category) leftViewport = false;
|
|
129
|
+
controller.setFilters(nextFilters);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createPageScope } from './lifecycle';
|
|
2
|
+
|
|
3
|
+
let scope = createPageScope();
|
|
4
|
+
const progress = (value: number): void => {
|
|
5
|
+
const bar = document.getElementById('page-loading-progress');
|
|
6
|
+
if (!bar) return;
|
|
7
|
+
bar.classList.toggle('loading', value < 100);
|
|
8
|
+
bar.classList.toggle('complete', value === 100);
|
|
9
|
+
bar.style.width = `${value}%`;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
document.addEventListener('astro:before-preparation', () => {
|
|
13
|
+
scope.dispose();
|
|
14
|
+
scope = createPageScope();
|
|
15
|
+
progress(0);
|
|
16
|
+
scope.frame(() => progress(30));
|
|
17
|
+
scope.timeout(() => progress(50), 100);
|
|
18
|
+
});
|
|
19
|
+
document.addEventListener('astro:after-swap', () => progress(90));
|
|
20
|
+
document.addEventListener('astro:page-load', () => {
|
|
21
|
+
scope.dispose();
|
|
22
|
+
scope = createPageScope();
|
|
23
|
+
progress(100);
|
|
24
|
+
scope.timeout(() => {
|
|
25
|
+
const bar = document.getElementById('page-loading-progress');
|
|
26
|
+
bar?.classList.remove('complete');
|
|
27
|
+
if (bar) bar.style.width = '0%';
|
|
28
|
+
}, 300);
|
|
29
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createProtectedArticleController, parseEncryptedArticle, onPage } from '@mintfolio/core/client';
|
|
2
|
+
import { createArticleToc } from './protectedToc';
|
|
3
|
+
|
|
4
|
+
onPage('[data-protected-post]', (root, scope) => {
|
|
5
|
+
const gate = root.querySelector<HTMLElement>('.password-gate');
|
|
6
|
+
const form = root.querySelector<HTMLFormElement>('.password-form');
|
|
7
|
+
const input = root.querySelector<HTMLInputElement>('#article-password');
|
|
8
|
+
const submit = form?.querySelector<HTMLButtonElement>('button[type="submit"]');
|
|
9
|
+
const status = root.querySelector<HTMLElement>('#password-status');
|
|
10
|
+
const content = root.querySelector<HTMLElement>('.protected-article-content');
|
|
11
|
+
const unlockedBar = root.querySelector<HTMLElement>('.unlocked-bar');
|
|
12
|
+
const data = root.querySelector('.encrypted-article-data');
|
|
13
|
+
if (!gate || !form || !input || !submit || !status || !content || !unlockedBar || !data) return;
|
|
14
|
+
if (!window.isSecureContext || !crypto.subtle) {
|
|
15
|
+
status.textContent = '当前环境不支持安全解密,请通过 HTTPS 或本机 localhost 打开。';
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
let payload;
|
|
19
|
+
try { payload = parseEncryptedArticle(JSON.parse(data.textContent ?? '')); }
|
|
20
|
+
catch { status.textContent = '加密文章数据无法读取,请刷新页面或联系作者。'; return; }
|
|
21
|
+
const notify = (): void => { document.dispatchEvent(new Event('article:content-changed')); };
|
|
22
|
+
const controller = createProtectedArticleController({
|
|
23
|
+
payload, postId: root.dataset.postId ?? '', signal: scope.signal,
|
|
24
|
+
onUnlock: (unlocked) => {
|
|
25
|
+
// Only presentation stays here: retain Astro's empty scoped-style wrapper.
|
|
26
|
+
const template = root.querySelector<HTMLTemplateElement>('[data-prose-template]');
|
|
27
|
+
const prose = template?.content.firstElementChild?.cloneNode(true);
|
|
28
|
+
if (!(prose instanceof HTMLElement)) throw new Error('Missing article presentation template');
|
|
29
|
+
prose.replaceChildren(unlocked.fragment);
|
|
30
|
+
content.replaceChildren(createArticleToc(root, unlocked.headings), prose);
|
|
31
|
+
input.value = '';
|
|
32
|
+
},
|
|
33
|
+
onClear: () => { input.value = ''; content.replaceChildren(); status.textContent = ''; input.removeAttribute('aria-invalid'); notify(); },
|
|
34
|
+
onState: (state) => {
|
|
35
|
+
input.disabled = submit.disabled = state === 'unlocking';
|
|
36
|
+
submit.textContent = state === 'unlocking' ? '正在解锁…' : '解锁文章';
|
|
37
|
+
if (state === 'unlocking') { status.textContent = ''; input.removeAttribute('aria-invalid'); form.setAttribute('aria-busy', 'true'); }
|
|
38
|
+
else form.removeAttribute('aria-busy');
|
|
39
|
+
root.dataset.state = state === 'unlocked' ? 'unlocked' : 'locked';
|
|
40
|
+
gate.hidden = state === 'unlocked';
|
|
41
|
+
content.hidden = unlockedBar.hidden = state !== 'unlocked';
|
|
42
|
+
if (state === 'unlocked') { notify(); content.focus({ preventScroll: true }); }
|
|
43
|
+
},
|
|
44
|
+
onError: () => { status.textContent = '密码不正确,或文章数据已损坏,请重试。'; input.setAttribute('aria-invalid', 'true'); input.focus(); },
|
|
45
|
+
});
|
|
46
|
+
input.disabled = submit.disabled = false;
|
|
47
|
+
form.addEventListener('submit', (event) => { event.preventDefault(); void controller.unlock(input.value); }, { signal: scope.signal });
|
|
48
|
+
root.querySelector('.lock-article')?.addEventListener('click', () => { controller.lock(); input.focus(); }, { signal: scope.signal });
|
|
49
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ArticleHeading } from '@mintfolio/theme-api/astro';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rebuild the Verdant Theme's TOC from authenticated headings after unlocking.
|
|
5
|
+
* The template contains no article metadata. Cloning its empty link preserves
|
|
6
|
+
* Astro's scoped CSS attributes; textContent keeps heading labels inert.
|
|
7
|
+
*/
|
|
8
|
+
export function createArticleToc(root: HTMLElement, headings: ArticleHeading[]): DocumentFragment {
|
|
9
|
+
const result = document.createDocumentFragment();
|
|
10
|
+
const visible = headings.filter((heading) => heading.depth === 2 || heading.depth === 3);
|
|
11
|
+
const template = root.querySelector<HTMLTemplateElement>('[data-toc-template]');
|
|
12
|
+
if (!visible.length || !template) return result;
|
|
13
|
+
|
|
14
|
+
const contents = template.content.cloneNode(true) as DocumentFragment;
|
|
15
|
+
const nav = contents.querySelector('.toc-nav');
|
|
16
|
+
const example = nav?.querySelector<HTMLAnchorElement>('.toc-link');
|
|
17
|
+
if (!nav || !example) return result;
|
|
18
|
+
|
|
19
|
+
const links = visible.map((heading): HTMLAnchorElement => {
|
|
20
|
+
const link = example.cloneNode(false) as HTMLAnchorElement;
|
|
21
|
+
link.className = `toc-link depth-${heading.depth}`;
|
|
22
|
+
link.setAttribute('href', `#${heading.slug}`);
|
|
23
|
+
link.textContent = heading.text;
|
|
24
|
+
return link;
|
|
25
|
+
});
|
|
26
|
+
nav.replaceChildren(...links);
|
|
27
|
+
result.append(contents);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
package/scripts/site.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { ARTICLE_BACK_NAV_STORAGE_KEY, shouldPersistArticleBackHref } from '../utils/blogNavigation';
|
|
2
|
+
import { HOME_RETURN_HINT_STORAGE_KEY, shouldShowHomeReturnHint } from '../utils/homeReturnHint';
|
|
3
|
+
import { readStorage, writeStorage } from '../utils/storage';
|
|
4
|
+
import { onPage } from './lifecycle';
|
|
5
|
+
import { initTheme } from './theme';
|
|
6
|
+
import { rememberArticleOrigin } from './articleOrigin';
|
|
7
|
+
import './postList';
|
|
8
|
+
import './progress';
|
|
9
|
+
|
|
10
|
+
function getCurrentArticleBackHref(): string {
|
|
11
|
+
const pathname = window.location.pathname;
|
|
12
|
+
const search = window.location.search;
|
|
13
|
+
const isHome = document.body.dataset.pageKind === 'home';
|
|
14
|
+
const hero = document.querySelector('.hero');
|
|
15
|
+
const isHomeContentVisible = isHome && hero instanceof HTMLElement && hero.classList.contains('collapsed');
|
|
16
|
+
|
|
17
|
+
if (isHomeContentVisible) {
|
|
18
|
+
return `${document.body.dataset.homeHref}#content`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return `${pathname}${search}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onPage('body', (_body, scope) => {
|
|
25
|
+
initTheme(scope);
|
|
26
|
+
const addDisposer = scope.add;
|
|
27
|
+
const floatRight = document.getElementById('ui-float-right');
|
|
28
|
+
if (!floatRight) return;
|
|
29
|
+
const backToTop = document.getElementById('back-to-top');
|
|
30
|
+
const homeReturnHintLayer = document.getElementById('home-return-hint-layer');
|
|
31
|
+
const homeReturnHint = document.getElementById('home-return-hint');
|
|
32
|
+
let homeReturnHintTimer: number | null = null;
|
|
33
|
+
document.addEventListener(
|
|
34
|
+
'click',
|
|
35
|
+
(event) => {
|
|
36
|
+
if (
|
|
37
|
+
event.defaultPrevented ||
|
|
38
|
+
event.button !== 0 ||
|
|
39
|
+
event.metaKey ||
|
|
40
|
+
event.ctrlKey ||
|
|
41
|
+
event.shiftKey ||
|
|
42
|
+
event.altKey
|
|
43
|
+
)
|
|
44
|
+
return;
|
|
45
|
+
const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null;
|
|
46
|
+
if (
|
|
47
|
+
!(anchor instanceof HTMLAnchorElement) ||
|
|
48
|
+
anchor.hasAttribute('download') ||
|
|
49
|
+
(anchor.target && anchor.target !== '_self')
|
|
50
|
+
)
|
|
51
|
+
return;
|
|
52
|
+
if (
|
|
53
|
+
shouldPersistArticleBackHref({
|
|
54
|
+
linkHref: anchor.href,
|
|
55
|
+
currentOrigin: location.origin,
|
|
56
|
+
currentPath: location.pathname,
|
|
57
|
+
isArticleLink: anchor.hasAttribute('data-article-link'),
|
|
58
|
+
})
|
|
59
|
+
) {
|
|
60
|
+
writeStorage('session', ARTICLE_BACK_NAV_STORAGE_KEY, getCurrentArticleBackHref());
|
|
61
|
+
rememberArticleOrigin(anchor.pathname, getCurrentArticleBackHref());
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{ capture: true, signal: scope.signal },
|
|
65
|
+
);
|
|
66
|
+
const hideHomeReturnHint = () => {
|
|
67
|
+
if (!homeReturnHintLayer) return;
|
|
68
|
+
homeReturnHintLayer.classList.add('hiding');
|
|
69
|
+
homeReturnHintLayer.classList.remove('visible');
|
|
70
|
+
homeReturnHintLayer.setAttribute('aria-hidden', 'true');
|
|
71
|
+
if (homeReturnHintTimer !== null) {
|
|
72
|
+
window.clearTimeout(homeReturnHintTimer);
|
|
73
|
+
homeReturnHintTimer = null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const showHomeReturnHint = () => {
|
|
78
|
+
if (!homeReturnHintLayer || !homeReturnHint) return;
|
|
79
|
+
if (!shouldShowHomeReturnHint(readStorage('local', HOME_RETURN_HINT_STORAGE_KEY))) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
writeStorage('local', HOME_RETURN_HINT_STORAGE_KEY, '1');
|
|
84
|
+
homeReturnHintLayer.classList.remove('hiding');
|
|
85
|
+
homeReturnHintLayer.classList.add('visible');
|
|
86
|
+
homeReturnHintLayer.setAttribute('aria-hidden', 'false');
|
|
87
|
+
|
|
88
|
+
if (homeReturnHintTimer !== null) {
|
|
89
|
+
window.clearTimeout(homeReturnHintTimer);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
homeReturnHintTimer = scope.timeout(() => {
|
|
93
|
+
hideHomeReturnHint();
|
|
94
|
+
}, 10000);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const hero = document.querySelector('.hero');
|
|
98
|
+
if (hero) {
|
|
99
|
+
const obs = new IntersectionObserver(
|
|
100
|
+
([entry]) => {
|
|
101
|
+
floatRight.classList.toggle('visible', !entry.isIntersecting);
|
|
102
|
+
},
|
|
103
|
+
{ threshold: 0.1 },
|
|
104
|
+
);
|
|
105
|
+
obs.observe(hero);
|
|
106
|
+
addDisposer(() => obs.disconnect());
|
|
107
|
+
} else {
|
|
108
|
+
floatRight.classList.add('visible');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (backToTop) {
|
|
112
|
+
const onBackToTopClick = () => {
|
|
113
|
+
const isHome = document.body.dataset.pageKind === 'home';
|
|
114
|
+
if (isHome && window.scrollY <= 8) {
|
|
115
|
+
hideHomeReturnHint();
|
|
116
|
+
window.dispatchEvent(new CustomEvent('home:return-to-hero'));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
window.scrollTo({
|
|
121
|
+
top: 0,
|
|
122
|
+
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth',
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
backToTop.addEventListener('click', onBackToTopClick);
|
|
126
|
+
addDisposer(() => backToTop.removeEventListener('click', onBackToTopClick));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (homeReturnHint) {
|
|
130
|
+
const onHomeReturnHintClick = () => {
|
|
131
|
+
hideHomeReturnHint();
|
|
132
|
+
window.dispatchEvent(new CustomEvent('home:return-to-hero'));
|
|
133
|
+
};
|
|
134
|
+
homeReturnHint.addEventListener('click', onHomeReturnHintClick);
|
|
135
|
+
addDisposer(() => homeReturnHint.removeEventListener('click', onHomeReturnHintClick));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const onShowHomeReturnHint = () => {
|
|
139
|
+
showHomeReturnHint();
|
|
140
|
+
};
|
|
141
|
+
window.addEventListener('home:show-return-hint', onShowHomeReturnHint);
|
|
142
|
+
addDisposer(() => window.removeEventListener('home:show-return-hint', onShowHomeReturnHint));
|
|
143
|
+
|
|
144
|
+
scope.add(hideHomeReturnHint);
|
|
145
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { PageScope } from './lifecycle';
|
|
2
|
+
|
|
3
|
+
/** Measure first, then write, and coalesce resize/filter updates into one frame. */
|
|
4
|
+
export function createTagFold(container: HTMLElement | null, toggle: HTMLElement | null, scope: PageScope) {
|
|
5
|
+
let expanded = false;
|
|
6
|
+
let pending = false;
|
|
7
|
+
const maxRows = Number(container?.closest<HTMLElement>('.tags-filter-block')?.dataset.maxRows) || 0;
|
|
8
|
+
|
|
9
|
+
const measure = (): void => {
|
|
10
|
+
pending = false;
|
|
11
|
+
if (!container || !toggle || !maxRows || !container.clientWidth) return;
|
|
12
|
+
const buttons = Array.from(container.querySelectorAll<HTMLButtonElement>('.tag-btn')).filter(
|
|
13
|
+
(button) => !button.hidden && !button.disabled,
|
|
14
|
+
);
|
|
15
|
+
const rows = Map.groupBy(buttons, (button) => Math.round(button.offsetTop));
|
|
16
|
+
const rowTops = [...rows.keys()];
|
|
17
|
+
const lastRow = rows.get(rowTops[maxRows - 1] ?? -1);
|
|
18
|
+
const lastButton = lastRow?.at(-1);
|
|
19
|
+
const height = lastButton ? lastButton.offsetTop + lastButton.offsetHeight - (rowTops[0] ?? 0) : 0;
|
|
20
|
+
const canFold = rows.size > maxRows;
|
|
21
|
+
const collapsed = canFold && !expanded;
|
|
22
|
+
const expandedHeight = container.scrollHeight;
|
|
23
|
+
|
|
24
|
+
container.classList.toggle('is-collapsed', collapsed);
|
|
25
|
+
if (height) container.style.setProperty('--collapsed-height', `${height}px`);
|
|
26
|
+
container.style.setProperty('--expanded-height', `${expandedHeight}px`);
|
|
27
|
+
toggle.hidden = !canFold;
|
|
28
|
+
toggle.textContent = collapsed ? '展开更多' : '收起标签';
|
|
29
|
+
toggle.setAttribute('aria-expanded', String(canFold && expanded));
|
|
30
|
+
};
|
|
31
|
+
const refresh = (): void => {
|
|
32
|
+
if (pending) return;
|
|
33
|
+
pending = true;
|
|
34
|
+
scope.frame(measure);
|
|
35
|
+
};
|
|
36
|
+
toggle?.addEventListener(
|
|
37
|
+
'click',
|
|
38
|
+
() => {
|
|
39
|
+
expanded = !expanded;
|
|
40
|
+
refresh();
|
|
41
|
+
},
|
|
42
|
+
{ signal: scope.signal },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (container) {
|
|
46
|
+
let previousWidth = -1;
|
|
47
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
48
|
+
if (entry && entry.contentRect.width !== previousWidth) {
|
|
49
|
+
previousWidth = entry.contentRect.width;
|
|
50
|
+
refresh();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
observer.observe(container);
|
|
54
|
+
scope.add(() => observer.disconnect());
|
|
55
|
+
void document.fonts.ready.then(() => {
|
|
56
|
+
if (!scope.signal.aborted) refresh();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
refresh();
|
|
60
|
+
return { refresh };
|
|
61
|
+
}
|
package/scripts/theme.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { PALETTES } from '../lib/palettes';
|
|
2
|
+
import { readStorage, writeStorage } from '../utils/storage';
|
|
3
|
+
import type { PageScope } from './lifecycle';
|
|
4
|
+
|
|
5
|
+
function syncBrowserThemeColor(): void {
|
|
6
|
+
const meta = document.querySelector<HTMLMetaElement>('#browser-theme-color');
|
|
7
|
+
const background = getComputedStyle(document.documentElement).backgroundColor;
|
|
8
|
+
if (meta && meta.content !== background) meta.content = background;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Preserve the chosen theme before Astro swaps the root and head, avoiding a
|
|
12
|
+
// brief return to the default background and browser-bar color between pages.
|
|
13
|
+
document.addEventListener('astro:before-swap', (event) => {
|
|
14
|
+
const theme = document.documentElement.dataset.theme;
|
|
15
|
+
if (theme) event.newDocument.documentElement.dataset.theme = theme;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function initTheme(scope: PageScope): void {
|
|
19
|
+
const panel = document.getElementById('theme-panel');
|
|
20
|
+
const toggle = document.getElementById('theme-toggle');
|
|
21
|
+
if (!panel || !toggle) return;
|
|
22
|
+
const options = { signal: scope.signal };
|
|
23
|
+
const modeButtons = panel.querySelectorAll<HTMLButtonElement>('.mode-btn');
|
|
24
|
+
const paletteButtons = panel.querySelectorAll<HTMLButtonElement>('.palette-btn');
|
|
25
|
+
const storedMode = readStorage('local', 'theme-mode');
|
|
26
|
+
let mode = ['auto', 'light', 'dark'].includes(storedMode ?? '') ? storedMode! : (document.body.dataset.initialMode ?? 'auto');
|
|
27
|
+
let palette = readStorage('local', 'theme-palette') ?? document.body.dataset.initialPalette ?? '1';
|
|
28
|
+
if (!PALETTES.some((entry) => entry.id === palette)) palette = '1';
|
|
29
|
+
const media = matchMedia('(prefers-color-scheme: dark)');
|
|
30
|
+
const apply = (announce = false): void => {
|
|
31
|
+
const resolvedMode = mode === 'auto' ? (media.matches ? 'dark' : 'light') : mode;
|
|
32
|
+
const theme = `${resolvedMode}-${palette}`;
|
|
33
|
+
document.documentElement.dataset.theme = theme;
|
|
34
|
+
syncBrowserThemeColor();
|
|
35
|
+
writeStorage('local', 'theme', theme);
|
|
36
|
+
writeStorage('local', 'theme-mode', mode);
|
|
37
|
+
writeStorage('local', 'theme-palette', palette);
|
|
38
|
+
for (const button of [...modeButtons, ...paletteButtons]) {
|
|
39
|
+
const selected = button.dataset.mode === mode || button.dataset.palette === palette;
|
|
40
|
+
button.classList.toggle('active', selected);
|
|
41
|
+
button.setAttribute('aria-checked', String(selected));
|
|
42
|
+
button.tabIndex = selected ? 0 : -1;
|
|
43
|
+
}
|
|
44
|
+
const announcer = document.getElementById('a11y-announcer');
|
|
45
|
+
if (announce && announcer) {
|
|
46
|
+
const label = mode === 'auto' ? '自动' : mode === 'dark' ? '深色' : '浅色';
|
|
47
|
+
announcer.textContent = `主题已切换为${label}模式,${PALETTES.find((entry) => entry.id === palette)?.name}配色`;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
for (const buttons of [modeButtons, paletteButtons]) {
|
|
51
|
+
buttons.forEach((button, index) => {
|
|
52
|
+
button.addEventListener(
|
|
53
|
+
'click',
|
|
54
|
+
() => {
|
|
55
|
+
mode = button.dataset.mode ?? mode;
|
|
56
|
+
palette = button.dataset.palette ?? palette;
|
|
57
|
+
apply(true);
|
|
58
|
+
},
|
|
59
|
+
options,
|
|
60
|
+
);
|
|
61
|
+
button.addEventListener(
|
|
62
|
+
'keydown',
|
|
63
|
+
(event) => {
|
|
64
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
const step = ['ArrowLeft', 'ArrowUp'].includes(event.key) ? -1 : 1;
|
|
67
|
+
const next =
|
|
68
|
+
buttons[
|
|
69
|
+
event.key === 'Home'
|
|
70
|
+
? 0
|
|
71
|
+
: event.key === 'End'
|
|
72
|
+
? buttons.length - 1
|
|
73
|
+
: (index + step + buttons.length) % buttons.length
|
|
74
|
+
];
|
|
75
|
+
next?.click();
|
|
76
|
+
next?.focus();
|
|
77
|
+
},
|
|
78
|
+
options,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
panel.addEventListener(
|
|
83
|
+
'toggle',
|
|
84
|
+
() => {
|
|
85
|
+
const open = panel.matches(':popover-open');
|
|
86
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
87
|
+
panel.setAttribute('aria-hidden', String(!open));
|
|
88
|
+
},
|
|
89
|
+
options,
|
|
90
|
+
);
|
|
91
|
+
media.addEventListener(
|
|
92
|
+
'change',
|
|
93
|
+
() => {
|
|
94
|
+
if (mode === 'auto') apply();
|
|
95
|
+
},
|
|
96
|
+
options,
|
|
97
|
+
);
|
|
98
|
+
apply();
|
|
99
|
+
scope.add(() => {
|
|
100
|
+
if (panel.matches(':popover-open')) panel.hidePopover();
|
|
101
|
+
});
|
|
102
|
+
}
|