@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.
Files changed (103) hide show
  1. package/README.md +29 -0
  2. package/package.json +41 -0
  3. package/src/assets/icons/IconArchive.svg +1 -0
  4. package/src/assets/icons/IconArrowLeft.svg +1 -0
  5. package/src/assets/icons/IconArrowNarrowUp.svg +1 -0
  6. package/src/assets/icons/IconArrowRight.svg +1 -0
  7. package/src/assets/icons/IconArticle.svg +1 -0
  8. package/src/assets/icons/IconBrandX.svg +1 -0
  9. package/src/assets/icons/IconCalendar.svg +1 -0
  10. package/src/assets/icons/IconChevronLeft.svg +1 -0
  11. package/src/assets/icons/IconChevronRight.svg +1 -0
  12. package/src/assets/icons/IconEdit.svg +1 -0
  13. package/src/assets/icons/IconFacebook.svg +1 -0
  14. package/src/assets/icons/IconGitHub.svg +1 -0
  15. package/src/assets/icons/IconHash.svg +1 -0
  16. package/src/assets/icons/IconHome.svg +1 -0
  17. package/src/assets/icons/IconLinkedin.svg +1 -0
  18. package/src/assets/icons/IconMail.svg +1 -0
  19. package/src/assets/icons/IconMenuDeep.svg +1 -0
  20. package/src/assets/icons/IconMoon.svg +1 -0
  21. package/src/assets/icons/IconPinterest.svg +1 -0
  22. package/src/assets/icons/IconProject.svg +1 -0
  23. package/src/assets/icons/IconRss.svg +1 -0
  24. package/src/assets/icons/IconSearch.svg +1 -0
  25. package/src/assets/icons/IconSeries.svg +1 -0
  26. package/src/assets/icons/IconSunHigh.svg +1 -0
  27. package/src/assets/icons/IconTag.svg +1 -0
  28. package/src/assets/icons/IconTelegram.svg +1 -0
  29. package/src/assets/icons/IconUser.svg +1 -0
  30. package/src/assets/icons/IconWhatsapp.svg +1 -0
  31. package/src/assets/icons/IconX.svg +1 -0
  32. package/src/components/ai/AIChatWidget.astro +377 -0
  33. package/src/components/blog/Comments.astro +527 -0
  34. package/src/components/blog/Copyright.astro +152 -0
  35. package/src/components/blog/EditPost.astro +59 -0
  36. package/src/components/blog/FloatingTOC.astro +260 -0
  37. package/src/components/blog/InlineTOC.astro +223 -0
  38. package/src/components/blog/PostActions.astro +306 -0
  39. package/src/components/blog/RelatedPosts.astro +60 -0
  40. package/src/components/blog/SeriesNav.astro +176 -0
  41. package/src/components/blog/ShareLinks.astro +26 -0
  42. package/src/components/nav/BackButton.astro +37 -0
  43. package/src/components/nav/BackToTopButton.astro +223 -0
  44. package/src/components/nav/Breadcrumb.astro +57 -0
  45. package/src/components/nav/FloatingActions.astro +206 -0
  46. package/src/components/nav/Footer.astro +107 -0
  47. package/src/components/nav/Header.astro +252 -0
  48. package/src/components/nav/Pagination.astro +45 -0
  49. package/src/components/social/Socials.astro +19 -0
  50. package/src/components/social/Sponsors.astro +34 -0
  51. package/src/components/social/Sponsorship.astro +44 -0
  52. package/src/components/ui/Alert.astro +28 -0
  53. package/src/components/ui/Card.astro +206 -0
  54. package/src/components/ui/Collapse.astro +82 -0
  55. package/src/components/ui/ColorPreview.astro +29 -0
  56. package/src/components/ui/Datetime.astro +61 -0
  57. package/src/components/ui/GithubCard.astro +191 -0
  58. package/src/components/ui/LinkButton.astro +21 -0
  59. package/src/components/ui/Tag.astro +37 -0
  60. package/src/components/ui/TagCloud.astro +69 -0
  61. package/src/components/ui/Timeline.astro +39 -0
  62. package/src/layouts/AboutLayout.astro +24 -0
  63. package/src/layouts/Layout.astro +329 -0
  64. package/src/layouts/Main.astro +42 -0
  65. package/src/layouts/PostDetails.astro +445 -0
  66. package/src/plugins/rehype-autolink-headings.ts +46 -0
  67. package/src/plugins/rehype-external-links.ts +35 -0
  68. package/src/plugins/rehype-table-scroll.ts +35 -0
  69. package/src/plugins/remark-add-zoomable.ts +28 -0
  70. package/src/plugins/remark-reading-time.ts +18 -0
  71. package/src/plugins/shiki-transformers.ts +212 -0
  72. package/src/scripts/lightbox.ts +63 -0
  73. package/src/scripts/reading-position.ts +56 -0
  74. package/src/scripts/theme-utils.ts +19 -0
  75. package/src/scripts/theme.ts +179 -0
  76. package/src/scripts/web-vitals.ts +96 -0
  77. package/src/styles/code-blocks.css +194 -0
  78. package/src/styles/components.css +252 -0
  79. package/src/styles/global.css +403 -0
  80. package/src/styles/typography.css +149 -0
  81. package/src/types.ts +89 -0
  82. package/src/utils/generateOgImages.ts +38 -0
  83. package/src/utils/getCategoryPath.ts +23 -0
  84. package/src/utils/getPath.ts +52 -0
  85. package/src/utils/getPostsByCategory.ts +17 -0
  86. package/src/utils/getPostsByGroupCondition.ts +25 -0
  87. package/src/utils/getPostsByLang.ts +27 -0
  88. package/src/utils/getPostsByTag.ts +10 -0
  89. package/src/utils/getReadingTime.ts +33 -0
  90. package/src/utils/getRelatedPosts.ts +59 -0
  91. package/src/utils/getSeriesData.ts +57 -0
  92. package/src/utils/getSortedPosts.ts +18 -0
  93. package/src/utils/getTagsWithCount.ts +38 -0
  94. package/src/utils/getUniqueCategories.ts +81 -0
  95. package/src/utils/getUniqueTags.ts +23 -0
  96. package/src/utils/i18n.ts +249 -0
  97. package/src/utils/loadGoogleFont.ts +38 -0
  98. package/src/utils/og-templates/post.js +229 -0
  99. package/src/utils/og-templates/site.js +128 -0
  100. package/src/utils/pathUtils.ts +17 -0
  101. package/src/utils/postFilter.ts +11 -0
  102. package/src/utils/slugify.ts +23 -0
  103. 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">&times;</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();