From b79e3e2e47ec8a3eff43dec3c9381f477b2fa56d Mon Sep 17 00:00:00 2001 From: grassblock Date: Wed, 6 Aug 2025 19:04:29 +0800 Subject: [PATCH] feat: add wordcount heatmap --- astro.config.mjs | 3 +- package.json | 3 + pnpm-lock.yaml | 34 ++++++ src/components/shortcodes/HeatMap.astro | 155 ++++++++++++++++++++++++ src/content/pages/now.mdx | 7 ++ src/plugins/heatmapdata/forgejo.js | 17 +++ src/plugins/heatmapdata/github.js | 94 ++++++++++++++ src/plugins/heatmapdata/local.js | 30 +++++ src/plugins/wordcount.js | 9 ++ 9 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 src/components/shortcodes/HeatMap.astro create mode 100644 src/content/pages/now.mdx create mode 100644 src/plugins/heatmapdata/forgejo.js create mode 100644 src/plugins/heatmapdata/github.js create mode 100644 src/plugins/heatmapdata/local.js create mode 100644 src/plugins/wordcount.js 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