diff --git a/astro.config.mjs b/astro.config.mjs
index 3e85640..d5cb799 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -4,6 +4,7 @@ import sitemap from '@astrojs/sitemap';
import mdx from '@astrojs/mdx';
+import { remarkWordCount } from './src/plugins/wordcount.js';
import cloudflare from '@astrojs/cloudflare';
import remarkMath from "remark-math";
@@ -27,7 +28,7 @@ export default defineConfig({
theme: 'nord',
wrap: true
},
- remarkPlugins: [ remarkMath ],
+ remarkPlugins: [ remarkMath, remarkWordCount ],
rehypePlugins: [ rehypeKatex ]
},
diff --git a/package.json b/package.json
index 3ea7f4c..7410bd3 100644
--- a/package.json
+++ b/package.json
@@ -19,9 +19,12 @@
"@fontsource-variable/jetbrains-mono": "^5.2.5",
"artalk": "^2.9.1",
"astro": "^5.2.5",
+ "echarts": "^6.0.0",
"giscus": "^1.6.0",
"ico-endec": "^0.1.6",
"katex": "^0.16.22",
+ "mdast-util-to-string": "^4.0.0",
+ "reading-time": "^1.5.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"sharp": "^0.34.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0bba656..81207d7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
astro:
specifier: ^5.2.5
version: 5.7.10(@azure/identity@4.9.1)(@types/node@22.15.3)(rollup@4.40.1)(typescript@5.8.3)(yaml@2.7.1)
+ echarts:
+ specifier: ^6.0.0
+ version: 6.0.0
giscus:
specifier: ^1.6.0
version: 1.6.0
@@ -41,6 +44,12 @@ importers:
katex:
specifier: ^0.16.22
version: 0.16.22
+ mdast-util-to-string:
+ specifier: ^4.0.0
+ version: 4.0.0
+ reading-time:
+ specifier: ^1.5.0
+ version: 1.5.0
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
@@ -1524,6 +1533,9 @@ packages:
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+ echarts@6.0.0:
+ resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
+
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -2628,6 +2640,9 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
+ reading-time@1.5.0:
+ resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==}
+
recma-build-jsx@1.0.0:
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
@@ -2977,6 +2992,9 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+ tslib@2.3.0:
+ resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3362,6 +3380,9 @@ packages:
zod@3.24.3:
resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==}
+ zrender@6.0.0:
+ resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
+
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -4814,6 +4835,11 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
+ echarts@6.0.0:
+ dependencies:
+ tslib: 2.3.0
+ zrender: 6.0.0
+
ee-first@1.1.1: {}
emoji-regex@10.4.0: {}
@@ -6350,6 +6376,8 @@ snapshots:
readdirp@4.1.2: {}
+ reading-time@1.5.0: {}
+
recma-build-jsx@1.0.0:
dependencies:
'@types/estree': 1.0.7
@@ -6864,6 +6892,8 @@ snapshots:
tslib@1.14.1: {}
+ tslib@2.3.0: {}
+
tslib@2.8.1: {}
tunnel-agent@0.6.0:
@@ -7176,4 +7206,8 @@ snapshots:
zod@3.24.3: {}
+ zrender@6.0.0:
+ dependencies:
+ tslib: 2.3.0
+
zwitch@2.0.4: {}
diff --git a/src/components/shortcodes/HeatMap.astro b/src/components/shortcodes/HeatMap.astro
new file mode 100644
index 0000000..d62c70d
--- /dev/null
+++ b/src/components/shortcodes/HeatMap.astro
@@ -0,0 +1,155 @@
+---
+// If you want to fetch data from GitHub or Forgejo, uncomment the lines containing the GitHub/Forgejo.
+// Since echarts does not support different color for different data sources,
+// we will use only local data for now.
+//import { fetchDataForAllYears } from "../../plugins/heatmapdata/github";
+//import { fetchForgejoData } from "../../plugins/heatmapdata/forgejo";
+import { generateLocalData } from "../../plugins/heatmapdata/local";
+interface Props {
+ GithubUserName?: string;
+ ForgejoInfo?: {
+ instance: string;
+ username: string;
+ }[];
+}
+const { GithubUserName, ForgejoInfo } = Astro.props;
+
+//const GithubData = GithubUserName ? await fetchDataForAllYears(GithubUserName) : null;
+//const ForgejoData = ForgejoInfo ? (await Promise.all(ForgejoInfo.map(info => fetchForgejoData(info.instance, info.username)))) : null;
+const LocalData = await generateLocalData();
+const allData = [
+// {
+// type: "heatmap",
+// coordinateSystem: 'calendar',
+// data: GithubData
+//
+// },
+ // {
+// type: "heatmap",
+// coordinateSystem: 'calendar',
+// data: ForgejoData
+// },
+ {
+ type: "heatmap",
+ coordinateSystem: 'calendar',
+ data: LocalData
+ }
+]
+---
+
+
+
\ No newline at end of file
diff --git a/src/content/pages/now.mdx b/src/content/pages/now.mdx
new file mode 100644
index 0000000..560e389
--- /dev/null
+++ b/src/content/pages/now.mdx
@@ -0,0 +1,7 @@
+---
+title: 'Now'
+description: 'This is a test page'
+---
+import HeatMap from "../../components/shortcodes/HeatMap.astro"
+
+
diff --git a/src/plugins/heatmapdata/forgejo.js b/src/plugins/heatmapdata/forgejo.js
new file mode 100644
index 0000000..40f42d9
--- /dev/null
+++ b/src/plugins/heatmapdata/forgejo.js
@@ -0,0 +1,17 @@
+export async function fetchForgejoData(instance, username) {
+ const response = await fetch(`https://${instance}/api/v1/users/${username}/heatmap`);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ const data = []
+ const rdata = await response.json();
+ Object.values(rdata).forEach((s) => {
+ data.push([
+ new Date(s.timestamp * 1000).toISOString().split('T')[0], // Convert seconds to milliseconds
+ s.contributions,
+ ])
+ })
+
+ return data
+}
diff --git a/src/plugins/heatmapdata/github.js b/src/plugins/heatmapdata/github.js
new file mode 100644
index 0000000..9ffa88a
--- /dev/null
+++ b/src/plugins/heatmapdata/github.js
@@ -0,0 +1,94 @@
+import {parse} from "ultrahtml"
+import { querySelector, querySelectorAll } from "ultrahtml/selector";
+
+async function fetchYears(username) {
+ const data = await fetch(`https://github.com/${username}?tab=contributions`, {
+ headers: {
+ "x-requested-with": "XMLHttpRequest"
+ }
+ });
+ const body = await data.text();
+ const rhtml = parse(body);
+ return querySelectorAll(rhtml,".js-year-link.filter-item")
+ .map((a) => {
+ const aEle = querySelector(a, "a");
+ const href = aEle.attributes.href;
+ const githubUrl = new URL(`https://github.com${href}`);
+ githubUrl.searchParams.set("tab", "contributions");
+ const formattedHref = `${githubUrl.pathname}${githubUrl.search}`;
+
+ return {
+ href: formattedHref,
+ text: aEle.text
+ };
+ });
+}
+
+async function fetchDataForYear(url, year, format) {
+ const data = await fetch(`https://github.com${url}`, {
+ headers: {
+ "x-requested-with": "XMLHttpRequest"
+ }
+ });
+ const rhtml = parse(await data.text());
+
+ const days = querySelectorAll(rhtml, "table.ContributionCalendar-grid td.ContributionCalendar-day");
+ const contribText = querySelector(rhtml,".js-yearly-contributions h2").children[0].value
+ .trim()
+ .match(/^([0-9,]+)\s/);
+ let contribCount;
+ if (contribText) {
+ [contribCount] = contribText;
+ contribCount = parseInt(contribCount.replace(/,/g, ""), 10);
+ }
+ return {
+ year,
+ total: contribCount || 0,
+ range: {
+ start: days[0].attributes['data-date'],
+ end: days[days.length - 1].attributes['data-date']
+ },
+ contributions: (() => {
+ const parseDay = (day, index) => {
+ const date = day.attributes['data-date'].split("-")
+ .map((d) => parseInt(d, 10));
+ const value = {
+ date: day.attributes['data-date'],
+ count: parseInt(day.attributes['data-level']) || 0
+ };
+ return { date, value };
+ };
+
+ if (format !== "nested") {
+ return days.map((day, index) => parseDay(day, index).value);
+ }
+
+ return days.reduce((o, day, index) => {
+ const { date, value } = parseDay(day, index);
+ const [y, m, d] = date;
+ if (!o[y]) o[y] = {};
+ if (!o[y][m]) o[y][m] = {};
+ o[y][m][d] = value;
+ return o;
+ }, {});
+ })()
+ };
+}
+
+export async function fetchDataForAllYears(username, format) {
+ const years = await fetchYears(username);
+ return Promise.all(
+ years.map((year) => fetchDataForYear(year.href, year.text, format))
+ ).then((resp) => {
+ // ECharts compatible format: [[date, value], ...]
+ return resp
+ .reduce((list, curr) => [...list, ...curr.contributions], [])
+ .filter((item) => item.count > 0)
+ .sort((a, b) => {
+ if (a.date < b.date) return -1;
+ else if (a.date > b.date) return 1;
+ return 0;
+ })
+ .map(item => [item.date, item.count]);
+ });
+}
\ No newline at end of file
diff --git a/src/plugins/heatmapdata/local.js b/src/plugins/heatmapdata/local.js
new file mode 100644
index 0000000..913b059
--- /dev/null
+++ b/src/plugins/heatmapdata/local.js
@@ -0,0 +1,30 @@
+import { getCollection, render } from 'astro:content';
+
+async function fetchPostsData() {
+ const posts = await getCollection('posts');
+ const entriesData = {};
+
+ for (const post of posts) {
+ const { remarkPluginFrontmatter } = await post.render();
+ const dateKey = post.data.pubDate.toISOString().split('T')[0]; // "2025-07-25"
+ entriesData[dateKey] = {
+ wordCount: remarkPluginFrontmatter.wordcount.words / 1000 || 0,
+ link: `/blog/${post.slug}`,
+ title: post.data.title
+ };
+ }
+
+ return entriesData;
+}
+
+export async function generateLocalData() {
+ const postsData = await fetchPostsData();
+ const data = []
+ Object.entries(postsData).forEach(([dateKey, entry]) => {
+ data.push([
+ new Date(dateKey).toISOString().split('T')[0], // Convert to YYYY-MM-DD format
+ entry.wordCount || 0,
+ ])
+ })
+ return data;
+}
\ No newline at end of file
diff --git a/src/plugins/wordcount.js b/src/plugins/wordcount.js
new file mode 100644
index 0000000..f25719d
--- /dev/null
+++ b/src/plugins/wordcount.js
@@ -0,0 +1,9 @@
+import {toString} from 'mdast-util-to-string';
+import countWords from 'reading-time'
+
+export function remarkWordCount() {
+ return function (tree, { data }) {
+ const textOnPage = toString(tree);
+ data.astro.frontmatter.wordcount = countWords(textOnPage);
+ };
+}
\ No newline at end of file