@astro-minimax/core 0.1.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/README.md +29 -0
- package/package.json +41 -0
- package/src/assets/icons/IconArchive.svg +1 -0
- package/src/assets/icons/IconArrowLeft.svg +1 -0
- package/src/assets/icons/IconArrowNarrowUp.svg +1 -0
- package/src/assets/icons/IconArrowRight.svg +1 -0
- package/src/assets/icons/IconArticle.svg +1 -0
- package/src/assets/icons/IconBrandX.svg +1 -0
- package/src/assets/icons/IconCalendar.svg +1 -0
- package/src/assets/icons/IconChevronLeft.svg +1 -0
- package/src/assets/icons/IconChevronRight.svg +1 -0
- package/src/assets/icons/IconEdit.svg +1 -0
- package/src/assets/icons/IconFacebook.svg +1 -0
- package/src/assets/icons/IconGitHub.svg +1 -0
- package/src/assets/icons/IconHash.svg +1 -0
- package/src/assets/icons/IconHome.svg +1 -0
- package/src/assets/icons/IconLinkedin.svg +1 -0
- package/src/assets/icons/IconMail.svg +1 -0
- package/src/assets/icons/IconMenuDeep.svg +1 -0
- package/src/assets/icons/IconMoon.svg +1 -0
- package/src/assets/icons/IconPinterest.svg +1 -0
- package/src/assets/icons/IconProject.svg +1 -0
- package/src/assets/icons/IconRss.svg +1 -0
- package/src/assets/icons/IconSearch.svg +1 -0
- package/src/assets/icons/IconSeries.svg +1 -0
- package/src/assets/icons/IconSunHigh.svg +1 -0
- package/src/assets/icons/IconTag.svg +1 -0
- package/src/assets/icons/IconTelegram.svg +1 -0
- package/src/assets/icons/IconUser.svg +1 -0
- package/src/assets/icons/IconWhatsapp.svg +1 -0
- package/src/assets/icons/IconX.svg +1 -0
- package/src/components/ai/AIChatWidget.astro +377 -0
- package/src/components/blog/Comments.astro +527 -0
- package/src/components/blog/Copyright.astro +152 -0
- package/src/components/blog/EditPost.astro +59 -0
- package/src/components/blog/FloatingTOC.astro +260 -0
- package/src/components/blog/InlineTOC.astro +223 -0
- package/src/components/blog/PostActions.astro +306 -0
- package/src/components/blog/RelatedPosts.astro +60 -0
- package/src/components/blog/SeriesNav.astro +176 -0
- package/src/components/blog/ShareLinks.astro +26 -0
- package/src/components/nav/BackButton.astro +37 -0
- package/src/components/nav/BackToTopButton.astro +223 -0
- package/src/components/nav/Breadcrumb.astro +57 -0
- package/src/components/nav/FloatingActions.astro +206 -0
- package/src/components/nav/Footer.astro +107 -0
- package/src/components/nav/Header.astro +252 -0
- package/src/components/nav/Pagination.astro +45 -0
- package/src/components/social/Socials.astro +19 -0
- package/src/components/social/Sponsors.astro +34 -0
- package/src/components/social/Sponsorship.astro +44 -0
- package/src/components/ui/Alert.astro +28 -0
- package/src/components/ui/Card.astro +206 -0
- package/src/components/ui/Collapse.astro +82 -0
- package/src/components/ui/ColorPreview.astro +29 -0
- package/src/components/ui/Datetime.astro +61 -0
- package/src/components/ui/GithubCard.astro +191 -0
- package/src/components/ui/LinkButton.astro +21 -0
- package/src/components/ui/Tag.astro +37 -0
- package/src/components/ui/TagCloud.astro +69 -0
- package/src/components/ui/Timeline.astro +39 -0
- package/src/layouts/AboutLayout.astro +24 -0
- package/src/layouts/Layout.astro +329 -0
- package/src/layouts/Main.astro +42 -0
- package/src/layouts/PostDetails.astro +445 -0
- package/src/plugins/rehype-autolink-headings.ts +46 -0
- package/src/plugins/rehype-external-links.ts +35 -0
- package/src/plugins/rehype-table-scroll.ts +35 -0
- package/src/plugins/remark-add-zoomable.ts +28 -0
- package/src/plugins/remark-reading-time.ts +18 -0
- package/src/plugins/shiki-transformers.ts +212 -0
- package/src/scripts/lightbox.ts +63 -0
- package/src/scripts/reading-position.ts +56 -0
- package/src/scripts/theme-utils.ts +19 -0
- package/src/scripts/theme.ts +179 -0
- package/src/scripts/web-vitals.ts +96 -0
- package/src/styles/code-blocks.css +194 -0
- package/src/styles/components.css +252 -0
- package/src/styles/global.css +403 -0
- package/src/styles/typography.css +149 -0
- package/src/types.ts +89 -0
- package/src/utils/generateOgImages.ts +38 -0
- package/src/utils/getCategoryPath.ts +23 -0
- package/src/utils/getPath.ts +52 -0
- package/src/utils/getPostsByCategory.ts +17 -0
- package/src/utils/getPostsByGroupCondition.ts +25 -0
- package/src/utils/getPostsByLang.ts +27 -0
- package/src/utils/getPostsByTag.ts +10 -0
- package/src/utils/getReadingTime.ts +33 -0
- package/src/utils/getRelatedPosts.ts +59 -0
- package/src/utils/getSeriesData.ts +57 -0
- package/src/utils/getSortedPosts.ts +18 -0
- package/src/utils/getTagsWithCount.ts +38 -0
- package/src/utils/getUniqueCategories.ts +81 -0
- package/src/utils/getUniqueTags.ts +23 -0
- package/src/utils/i18n.ts +249 -0
- package/src/utils/loadGoogleFont.ts +38 -0
- package/src/utils/og-templates/post.js +229 -0
- package/src/utils/og-templates/site.js +128 -0
- package/src/utils/pathUtils.ts +17 -0
- package/src/utils/postFilter.ts +11 -0
- package/src/utils/slugify.ts +23 -0
- package/src/utils/toc.ts +27 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
interface HastElement {
|
|
2
|
+
type: "element";
|
|
3
|
+
tagName: string;
|
|
4
|
+
properties: Record<string, unknown>;
|
|
5
|
+
children: (HastElement | HastText)[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface HastText {
|
|
9
|
+
type: "text";
|
|
10
|
+
value: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ShikiTransformerContext {
|
|
14
|
+
options: {
|
|
15
|
+
lang: string;
|
|
16
|
+
meta?: { __raw?: string; [key: string]: unknown };
|
|
17
|
+
};
|
|
18
|
+
source: string;
|
|
19
|
+
lines: unknown[];
|
|
20
|
+
addClassToHast?: (node: HastElement, cls: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ShikiTransformer {
|
|
24
|
+
name: string;
|
|
25
|
+
pre?: (this: ShikiTransformerContext, node: HastElement) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function el(
|
|
29
|
+
tag: string,
|
|
30
|
+
props: Record<string, unknown>,
|
|
31
|
+
children: (HastElement | HastText)[] | string = []
|
|
32
|
+
): HastElement {
|
|
33
|
+
return {
|
|
34
|
+
type: "element",
|
|
35
|
+
tagName: tag,
|
|
36
|
+
properties: props,
|
|
37
|
+
children:
|
|
38
|
+
typeof children === "string"
|
|
39
|
+
? [{ type: "text" as const, value: children }]
|
|
40
|
+
: children,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseMetaString(str = ""): Record<string, string | true> {
|
|
45
|
+
return Object.fromEntries(
|
|
46
|
+
str.split(" ").reduce(
|
|
47
|
+
(acc: [string, string | true][], cur) => {
|
|
48
|
+
const matched = cur.match(/(.+)?=("(.+)"|'(.+)')$/);
|
|
49
|
+
if (matched === null) return acc;
|
|
50
|
+
const key = matched[1];
|
|
51
|
+
const value = matched[3] || matched[4] || true;
|
|
52
|
+
acc.push([key, value]);
|
|
53
|
+
return acc;
|
|
54
|
+
},
|
|
55
|
+
[] as [string, string | true][]
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Wraps the default `<pre>` in a `<div>` container for richer layout
|
|
62
|
+
* (title bar, language label, copy button, collapse toggle).
|
|
63
|
+
*/
|
|
64
|
+
export const updateStyle = (): ShikiTransformer => ({
|
|
65
|
+
name: "shiki-transformer-update-style",
|
|
66
|
+
pre(node) {
|
|
67
|
+
const container: HastElement = {
|
|
68
|
+
type: "element",
|
|
69
|
+
tagName: "pre",
|
|
70
|
+
properties: {},
|
|
71
|
+
children: node.children,
|
|
72
|
+
};
|
|
73
|
+
node.children = [container];
|
|
74
|
+
node.tagName = "div";
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parses `title="..."` or `file="..."` from the code-fence meta string
|
|
80
|
+
* and prepends a styled title bar to the code block.
|
|
81
|
+
*/
|
|
82
|
+
export const addTitle = (): ShikiTransformer => ({
|
|
83
|
+
name: "shiki-transformer-add-title",
|
|
84
|
+
pre(node) {
|
|
85
|
+
const rawMeta = this.options.meta?.__raw;
|
|
86
|
+
if (!rawMeta) return;
|
|
87
|
+
const meta = parseMetaString(rawMeta);
|
|
88
|
+
const label = meta.title || meta.file;
|
|
89
|
+
if (!label) return;
|
|
90
|
+
|
|
91
|
+
node.children.unshift(
|
|
92
|
+
el("div", { class: "code-title" }, label.toString())
|
|
93
|
+
);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Appends a language label (e.g. `ts`, `css`) to the top-right of the block.
|
|
99
|
+
*/
|
|
100
|
+
export const addLanguage = (): ShikiTransformer => ({
|
|
101
|
+
name: "shiki-transformer-add-language",
|
|
102
|
+
pre(node) {
|
|
103
|
+
node.children.push(el("span", { class: "code-language" }, this.options.lang));
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const clipboardSvg: HastElement = el(
|
|
108
|
+
"svg",
|
|
109
|
+
{
|
|
110
|
+
width: "16",
|
|
111
|
+
height: "16",
|
|
112
|
+
viewBox: "0 0 24 24",
|
|
113
|
+
fill: "none",
|
|
114
|
+
stroke: "currentColor",
|
|
115
|
+
"stroke-width": "2",
|
|
116
|
+
"stroke-linecap": "round",
|
|
117
|
+
"stroke-linejoin": "round",
|
|
118
|
+
},
|
|
119
|
+
[
|
|
120
|
+
el("rect", {
|
|
121
|
+
x: "9",
|
|
122
|
+
y: "9",
|
|
123
|
+
width: "13",
|
|
124
|
+
height: "13",
|
|
125
|
+
rx: "2",
|
|
126
|
+
ry: "2",
|
|
127
|
+
}),
|
|
128
|
+
el("path", {
|
|
129
|
+
d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1",
|
|
130
|
+
}),
|
|
131
|
+
]
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const checkSvg: HastElement = el(
|
|
135
|
+
"svg",
|
|
136
|
+
{
|
|
137
|
+
width: "16",
|
|
138
|
+
height: "16",
|
|
139
|
+
viewBox: "0 0 24 24",
|
|
140
|
+
fill: "none",
|
|
141
|
+
stroke: "currentColor",
|
|
142
|
+
"stroke-width": "2",
|
|
143
|
+
"stroke-linecap": "round",
|
|
144
|
+
"stroke-linejoin": "round",
|
|
145
|
+
},
|
|
146
|
+
[el("polyline", { points: "20 6 9 17 4 12" })]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Injects a copy-to-clipboard button at build time.
|
|
151
|
+
*/
|
|
152
|
+
export const addCopyButton = (timeout = 2000): ShikiTransformer => ({
|
|
153
|
+
name: "shiki-transformer-copy-button",
|
|
154
|
+
pre(node) {
|
|
155
|
+
node.children.push(
|
|
156
|
+
el(
|
|
157
|
+
"button",
|
|
158
|
+
{
|
|
159
|
+
class: "code-copy",
|
|
160
|
+
"aria-label": "Copy code",
|
|
161
|
+
"data-code": this.source,
|
|
162
|
+
onclick: `navigator.clipboard.writeText(this.dataset.code);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),${timeout})`,
|
|
163
|
+
},
|
|
164
|
+
[
|
|
165
|
+
el("span", { class: "ready" }, [clipboardSvg]),
|
|
166
|
+
el("span", { class: "success" }, [checkSvg]),
|
|
167
|
+
]
|
|
168
|
+
)
|
|
169
|
+
);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const chevronSvg: HastElement = el(
|
|
174
|
+
"svg",
|
|
175
|
+
{
|
|
176
|
+
width: "16",
|
|
177
|
+
height: "16",
|
|
178
|
+
viewBox: "0 0 24 24",
|
|
179
|
+
fill: "none",
|
|
180
|
+
stroke: "currentColor",
|
|
181
|
+
"stroke-width": "2",
|
|
182
|
+
"stroke-linecap": "round",
|
|
183
|
+
"stroke-linejoin": "round",
|
|
184
|
+
},
|
|
185
|
+
[el("polyline", { points: "6 9 12 15 18 9" })]
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Collapses code blocks that exceed `maxLines` lines.
|
|
190
|
+
*/
|
|
191
|
+
export const addCollapse = (maxLines = 15): ShikiTransformer => ({
|
|
192
|
+
name: "shiki-transformer-add-collapse",
|
|
193
|
+
pre(node) {
|
|
194
|
+
if (this.lines.length <= maxLines) return;
|
|
195
|
+
node.properties = {
|
|
196
|
+
...node.properties,
|
|
197
|
+
class: `${(node.properties?.class as string) || ""} collapsed`,
|
|
198
|
+
};
|
|
199
|
+
node.children.push(
|
|
200
|
+
el(
|
|
201
|
+
"button",
|
|
202
|
+
{
|
|
203
|
+
class: "code-collapse-toggle",
|
|
204
|
+
"aria-label": "Toggle collapse code block",
|
|
205
|
+
onclick: "this.parentElement.classList.toggle('collapsed')",
|
|
206
|
+
},
|
|
207
|
+
[chevronSvg, el("span", {}, " Expand")]
|
|
208
|
+
)
|
|
209
|
+
);
|
|
210
|
+
node.children.push(el("div", { class: "code-collapse-fade" }));
|
|
211
|
+
},
|
|
212
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
function initLightbox() {
|
|
2
|
+
const article = document.getElementById("article");
|
|
3
|
+
if (!article) return;
|
|
4
|
+
|
|
5
|
+
let overlay: HTMLDivElement | null = null;
|
|
6
|
+
|
|
7
|
+
function createOverlay() {
|
|
8
|
+
if (overlay) return overlay;
|
|
9
|
+
overlay = document.createElement("div");
|
|
10
|
+
overlay.id = "lightbox-overlay";
|
|
11
|
+
overlay.setAttribute("role", "dialog");
|
|
12
|
+
overlay.setAttribute("aria-modal", "true");
|
|
13
|
+
overlay.setAttribute("aria-label", "Image preview");
|
|
14
|
+
overlay.innerHTML = `
|
|
15
|
+
<button id="lightbox-close" aria-label="Close" class="lightbox-close">×</button>
|
|
16
|
+
<img id="lightbox-img" src="" alt="" />
|
|
17
|
+
`;
|
|
18
|
+
document.body.appendChild(overlay);
|
|
19
|
+
|
|
20
|
+
overlay.addEventListener("click", e => {
|
|
21
|
+
if (
|
|
22
|
+
e.target === overlay ||
|
|
23
|
+
(e.target as HTMLElement).id === "lightbox-close"
|
|
24
|
+
) {
|
|
25
|
+
close();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return overlay;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function open(src: string, alt: string) {
|
|
33
|
+
const el = createOverlay();
|
|
34
|
+
const img = el.querySelector("#lightbox-img") as HTMLImageElement;
|
|
35
|
+
img.src = src;
|
|
36
|
+
img.alt = alt;
|
|
37
|
+
el.classList.add("active");
|
|
38
|
+
document.body.style.overflow = "hidden";
|
|
39
|
+
document.addEventListener("keydown", onKey);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function close() {
|
|
43
|
+
if (!overlay) return;
|
|
44
|
+
overlay.classList.remove("active");
|
|
45
|
+
document.body.style.overflow = "";
|
|
46
|
+
document.removeEventListener("keydown", onKey);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onKey(e: KeyboardEvent) {
|
|
50
|
+
if (e.key === "Escape") close();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const images = article.querySelectorAll("img");
|
|
54
|
+
images.forEach(img => {
|
|
55
|
+
if (img.closest("a")) return;
|
|
56
|
+
img.style.cursor = "zoom-in";
|
|
57
|
+
img.addEventListener("click", () => {
|
|
58
|
+
open(img.src, img.alt || "");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
document.addEventListener("astro:page-load", initLightbox);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const STORAGE_KEY = "reading-positions";
|
|
2
|
+
const MAX_ENTRIES = 50;
|
|
3
|
+
|
|
4
|
+
interface ReadingEntry {
|
|
5
|
+
scrollY: number;
|
|
6
|
+
ts: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getPositions(): Record<string, ReadingEntry> {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "{}");
|
|
12
|
+
} catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function savePosition(path: string, scrollY: number) {
|
|
18
|
+
const positions = getPositions();
|
|
19
|
+
positions[path] = { scrollY, ts: Date.now() };
|
|
20
|
+
|
|
21
|
+
const keys = Object.keys(positions);
|
|
22
|
+
if (keys.length > MAX_ENTRIES) {
|
|
23
|
+
const sorted = keys.sort((a, b) => positions[a].ts - positions[b].ts);
|
|
24
|
+
for (let i = 0; i < keys.length - MAX_ENTRIES; i++) {
|
|
25
|
+
delete positions[sorted[i]];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function initReadingPosition() {
|
|
33
|
+
const article = document.getElementById("article");
|
|
34
|
+
if (!article) return;
|
|
35
|
+
|
|
36
|
+
const path = window.location.pathname;
|
|
37
|
+
const positions = getPositions();
|
|
38
|
+
const entry = positions[path];
|
|
39
|
+
|
|
40
|
+
if (entry && entry.scrollY > 100) {
|
|
41
|
+
requestAnimationFrame(() => {
|
|
42
|
+
window.scrollTo({ top: entry.scrollY, behavior: "instant" });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let rafId: number | null = null;
|
|
47
|
+
document.addEventListener("scroll", () => {
|
|
48
|
+
if (rafId) return;
|
|
49
|
+
rafId = requestAnimationFrame(() => {
|
|
50
|
+
savePosition(path, window.scrollY);
|
|
51
|
+
rafId = null;
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
document.addEventListener("astro:page-load", initReadingPosition);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function getIsDark(): boolean {
|
|
2
|
+
return document.documentElement.getAttribute("data-theme") === "dark";
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function getCSSVar(name: string): string {
|
|
6
|
+
return getComputedStyle(document.documentElement)
|
|
7
|
+
.getPropertyValue(name)
|
|
8
|
+
.trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function onThemeChange(
|
|
12
|
+
callback: (isDark: boolean) => void
|
|
13
|
+
): () => void {
|
|
14
|
+
const handler = (e: CustomEvent<ThemeChangeDetail>) => {
|
|
15
|
+
callback(e.detail.isDark);
|
|
16
|
+
};
|
|
17
|
+
window.addEventListener("themechange", handler);
|
|
18
|
+
return () => window.removeEventListener("themechange", handler);
|
|
19
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Constants
|
|
2
|
+
|
|
3
|
+
const THEME = "theme";
|
|
4
|
+
const LIGHT = "light";
|
|
5
|
+
const DARK = "dark";
|
|
6
|
+
|
|
7
|
+
// Initial color scheme
|
|
8
|
+
// Can be "light", "dark", or empty string for system's prefers-color-scheme
|
|
9
|
+
const initialColorScheme = "";
|
|
10
|
+
|
|
11
|
+
function getPreferTheme(): string {
|
|
12
|
+
// get theme data from local storage (user's explicit choice)
|
|
13
|
+
const currentTheme = localStorage.getItem(THEME);
|
|
14
|
+
if (currentTheme) return currentTheme;
|
|
15
|
+
|
|
16
|
+
// return initial color scheme if it is set (site default)
|
|
17
|
+
if (initialColorScheme) return initialColorScheme;
|
|
18
|
+
|
|
19
|
+
// return user device's prefer color scheme (system fallback)
|
|
20
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
21
|
+
? DARK
|
|
22
|
+
: LIGHT;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Use existing theme value from inline script if available, otherwise detect
|
|
26
|
+
let themeValue = window.theme?.themeValue ?? getPreferTheme();
|
|
27
|
+
|
|
28
|
+
function setPreference(): void {
|
|
29
|
+
localStorage.setItem(THEME, themeValue);
|
|
30
|
+
reflectPreference();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function reflectPreference(): void {
|
|
34
|
+
document.firstElementChild?.setAttribute("data-theme", themeValue);
|
|
35
|
+
|
|
36
|
+
document.querySelector("#theme-btn")?.setAttribute("aria-label", themeValue);
|
|
37
|
+
|
|
38
|
+
const body = document.body;
|
|
39
|
+
if (body) {
|
|
40
|
+
const computedStyles = window.getComputedStyle(body);
|
|
41
|
+
const bgColor = computedStyles.backgroundColor;
|
|
42
|
+
document
|
|
43
|
+
.querySelector("meta[name='theme-color']")
|
|
44
|
+
?.setAttribute("content", bgColor);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Defer viz re-renders to avoid blocking the theme transition animation
|
|
48
|
+
requestAnimationFrame(() => {
|
|
49
|
+
window.dispatchEvent(
|
|
50
|
+
new CustomEvent("themechange", {
|
|
51
|
+
detail: { isDark: themeValue === DARK, theme: themeValue },
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check if View Transitions API is supported
|
|
58
|
+
function supportsViewTransitions(): boolean {
|
|
59
|
+
return "startViewTransition" in document;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Toggle theme with optional View Transition animation
|
|
63
|
+
function toggleThemeWithTransition(event?: MouseEvent): void {
|
|
64
|
+
const newTheme = themeValue === LIGHT ? DARK : LIGHT;
|
|
65
|
+
|
|
66
|
+
// Check for reduced motion preference
|
|
67
|
+
const prefersReducedMotion = window.matchMedia(
|
|
68
|
+
"(prefers-reduced-motion: reduce)"
|
|
69
|
+
).matches;
|
|
70
|
+
|
|
71
|
+
// Get click position for circular animation
|
|
72
|
+
const x = event?.clientX ?? window.innerWidth / 2;
|
|
73
|
+
const y = event?.clientY ?? window.innerHeight / 2;
|
|
74
|
+
|
|
75
|
+
// Update theme value
|
|
76
|
+
themeValue = newTheme;
|
|
77
|
+
window.theme?.setTheme(themeValue);
|
|
78
|
+
|
|
79
|
+
// Skip animations if user prefers reduced motion
|
|
80
|
+
if (prefersReducedMotion) {
|
|
81
|
+
setPreference();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check if View Transitions API is supported
|
|
86
|
+
if (supportsViewTransitions()) {
|
|
87
|
+
// Set CSS variables for animation origin
|
|
88
|
+
document.documentElement.style.setProperty("--theme-x", `${x}px`);
|
|
89
|
+
document.documentElement.style.setProperty("--theme-y", `${y}px`);
|
|
90
|
+
|
|
91
|
+
// Add transition class
|
|
92
|
+
const html = document.documentElement;
|
|
93
|
+
html.classList.add("theme-transition");
|
|
94
|
+
|
|
95
|
+
if (newTheme === DARK) {
|
|
96
|
+
html.classList.add("dark-transition");
|
|
97
|
+
} else {
|
|
98
|
+
html.classList.remove("dark-transition");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Start View Transition with optimized timing
|
|
102
|
+
const transition = document.startViewTransition?.(() => {
|
|
103
|
+
setPreference();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Clean up classes after animation completes
|
|
107
|
+
transition?.finished.then(() => {
|
|
108
|
+
html.classList.remove("theme-transition", "dark-transition");
|
|
109
|
+
});
|
|
110
|
+
} else {
|
|
111
|
+
// Fallback for browsers without View Transitions API
|
|
112
|
+
document.documentElement.classList.add("no-view-transitions");
|
|
113
|
+
setPreference();
|
|
114
|
+
|
|
115
|
+
// Remove class after transition completes (optimized timing)
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
document.documentElement.classList.remove("no-view-transitions");
|
|
118
|
+
}, 400);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Update the global theme API
|
|
123
|
+
if (window.theme) {
|
|
124
|
+
window.theme.setPreference = setPreference;
|
|
125
|
+
window.theme.reflectPreference = reflectPreference;
|
|
126
|
+
} else {
|
|
127
|
+
window.theme = {
|
|
128
|
+
themeValue,
|
|
129
|
+
setPreference,
|
|
130
|
+
reflectPreference,
|
|
131
|
+
getTheme: () => themeValue,
|
|
132
|
+
setTheme: (val: string) => {
|
|
133
|
+
themeValue = val;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Ensure theme is reflected (in case body wasn't ready when inline script ran)
|
|
139
|
+
reflectPreference();
|
|
140
|
+
|
|
141
|
+
function setThemeFeature(): void {
|
|
142
|
+
// set on load so screen readers can get the latest value on the button
|
|
143
|
+
reflectPreference();
|
|
144
|
+
|
|
145
|
+
// now this script can find and listen for clicks on the control
|
|
146
|
+
document.querySelector("#theme-btn")?.addEventListener("click", e => {
|
|
147
|
+
toggleThemeWithTransition(e as MouseEvent);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Set up theme features after page load
|
|
152
|
+
setThemeFeature();
|
|
153
|
+
|
|
154
|
+
// Runs on view transitions navigation
|
|
155
|
+
document.addEventListener("astro:after-swap", setThemeFeature);
|
|
156
|
+
|
|
157
|
+
// Set theme-color value before page transition
|
|
158
|
+
// to avoid navigation bar color flickering in Android dark mode
|
|
159
|
+
document.addEventListener("astro:before-swap", event => {
|
|
160
|
+
const astroEvent = event;
|
|
161
|
+
const bgColor = document
|
|
162
|
+
.querySelector("meta[name='theme-color']")
|
|
163
|
+
?.getAttribute("content");
|
|
164
|
+
|
|
165
|
+
if (bgColor) {
|
|
166
|
+
astroEvent.newDocument
|
|
167
|
+
.querySelector("meta[name='theme-color']")
|
|
168
|
+
?.setAttribute("content", bgColor);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// sync with system changes
|
|
173
|
+
window
|
|
174
|
+
.matchMedia("(prefers-color-scheme: dark)")
|
|
175
|
+
.addEventListener("change", ({ matches: isDark }) => {
|
|
176
|
+
themeValue = isDark ? DARK : LIGHT;
|
|
177
|
+
window.theme?.setTheme(themeValue);
|
|
178
|
+
setPreference();
|
|
179
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
type WebVitalMetric = {
|
|
2
|
+
name: string;
|
|
3
|
+
value: number;
|
|
4
|
+
rating: "good" | "needs-improvement" | "poor";
|
|
5
|
+
id: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function getRating(name: string, value: number): WebVitalMetric["rating"] {
|
|
9
|
+
const thresholds: Record<string, [number, number]> = {
|
|
10
|
+
CLS: [0.1, 0.25],
|
|
11
|
+
FID: [100, 300],
|
|
12
|
+
INP: [200, 500],
|
|
13
|
+
LCP: [2500, 4000],
|
|
14
|
+
FCP: [1800, 3000],
|
|
15
|
+
TTFB: [800, 1800],
|
|
16
|
+
};
|
|
17
|
+
const [good, poor] = thresholds[name] ?? [0, 0];
|
|
18
|
+
if (value <= good) return "good";
|
|
19
|
+
if (value <= poor) return "needs-improvement";
|
|
20
|
+
return "poor";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function reportMetric(metric: WebVitalMetric) {
|
|
24
|
+
if (import.meta.env.DEV) {
|
|
25
|
+
const color =
|
|
26
|
+
metric.rating === "good"
|
|
27
|
+
? "#0cce6b"
|
|
28
|
+
: metric.rating === "needs-improvement"
|
|
29
|
+
? "#ffa400"
|
|
30
|
+
: "#ff4e42";
|
|
31
|
+
// eslint-disable-next-line no-console
|
|
32
|
+
console.log(
|
|
33
|
+
`%c[Web Vitals] ${metric.name}: ${metric.value.toFixed(1)}ms (${metric.rating})`,
|
|
34
|
+
`color: ${color}; font-weight: bold;`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function observeWebVitals() {
|
|
40
|
+
if (typeof PerformanceObserver === "undefined") return;
|
|
41
|
+
|
|
42
|
+
const id = `v${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const lcpObserver = new PerformanceObserver(list => {
|
|
46
|
+
const entries = list.getEntries();
|
|
47
|
+
const last = entries[entries.length - 1];
|
|
48
|
+
if (last) {
|
|
49
|
+
reportMetric({
|
|
50
|
+
name: "LCP",
|
|
51
|
+
value: last.startTime,
|
|
52
|
+
rating: getRating("LCP", last.startTime),
|
|
53
|
+
id,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
|
58
|
+
|
|
59
|
+
const fcpObserver = new PerformanceObserver(list => {
|
|
60
|
+
for (const entry of list.getEntries()) {
|
|
61
|
+
if (entry.name === "first-contentful-paint") {
|
|
62
|
+
reportMetric({
|
|
63
|
+
name: "FCP",
|
|
64
|
+
value: entry.startTime,
|
|
65
|
+
rating: getRating("FCP", entry.startTime),
|
|
66
|
+
id,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
fcpObserver.observe({ type: "paint", buffered: true });
|
|
72
|
+
|
|
73
|
+
const clsObserver = new PerformanceObserver(list => {
|
|
74
|
+
let clsValue = 0;
|
|
75
|
+
for (const entry of list.getEntries()) {
|
|
76
|
+
if (
|
|
77
|
+
!(entry as PerformanceEntry & { hadRecentInput?: boolean })
|
|
78
|
+
.hadRecentInput
|
|
79
|
+
) {
|
|
80
|
+
clsValue += (entry as PerformanceEntry & { value: number }).value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
reportMetric({
|
|
84
|
+
name: "CLS",
|
|
85
|
+
value: clsValue,
|
|
86
|
+
rating: getRating("CLS", clsValue),
|
|
87
|
+
id,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
clsObserver.observe({ type: "layout-shift", buffered: true });
|
|
91
|
+
} catch {
|
|
92
|
+
// PerformanceObserver types not supported in this browser
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
observeWebVitals();
|