feat: add wordcount heatmap

This commit is contained in:
草师傅 2025-08-06 19:04:29 +08:00
parent 8773b9b0df
commit b79e3e2e47
Signed by: gb
GPG key ID: 43330A030E2D6478
9 changed files with 351 additions and 1 deletions

View file

@ -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 ]
},

View file

@ -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",

34
pnpm-lock.yaml generated
View file

@ -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: {}

View file

@ -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
}
]
---
<div class="heatmap">
<div class="chart" id="heatmap-chart"></div>
<div data-chartdata={JSON.stringify(allData)} id="heatmap-data" class="hidden">
<!-- This div holds the chart data in JSON format for ECharts to use -->
</div>
</div>
<script>
import * as echarts from 'echarts';
console.log(JSON.parse(document.getElementById('heatmap-data')?.getAttribute('data-chartdata')))
function setupHeatmap() {
const chartElement = document.getElementById('heatmap-chart');
if (!chartElement) {
console.error('Heatmap chart element not found');
return;
}
const chart = echarts.init(chartElement);
let monthsAgo = 12; // One year ago
function getRangeArr() {
const windowWidth = window.innerWidth;
if (windowWidth >= 600) {
monthsAgo = 12; // One year ago
} else if (windowWidth >= 400) {
monthsAgo = 9; // Nine months ago
} else {
monthsAgo = 6;
}
}
getRangeArr();
const startDate = echarts.time.format(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000 * monthsAgo),'{yyyy}-{MM}-{dd}', false); // One year before today
const endDate = echarts.time.format(new Date(),'{yyyy}-{MM}-{dd}', false)
const textColor = window.getComputedStyle(document.documentElement).getPropertyValue('--secondary-text-color') || '#000';
const options = {
title: {
top: 0,
left: 'center',
text: 'Blog Post Heatmap',
textStyle: {
color: textColor,
fontFamily: 'monospace',
}
},
tooltip: {
hideDelay: 1000,
enterable: true,
},
visualMap: {
min: 0,
max: 5,
type: 'piecewise',
orient: 'horizontal',
left: 'center',
top: 25,
inRange: {
// [floor color, ceiling color]
color: ['#90a8c0', '#486090']
},
textStyle: {
color: textColor,
fontFamily: 'monospace',
},
splitNumber: 4,
text: ['k words', ''],
showLabel: true,
itemGap: 20,
},
calendar: {
top: 80,
left: 20,
right: 4,
cellSize: ['auto', 12],
range: [startDate, endDate],
itemStyle: {
color: 'rgba(255, 255, 255, 0.3)', // transparent background
borderWidth: 1.5,
borderColor: '#fafafa',
},
dayLabel: {
color: textColor,
fontFamily: 'monospace',
},
monthLabel: {
color: textColor,
fontFamily: 'monospace',
margin: 10,
fontSize: 12,
},
yearLabel: { show: false },
// the splitline between months. set to transparent for now.
splitLine: {
lineStyle: {
color: 'rgba(0, 0, 0, 0.0)',
}
}
},
series: JSON.parse(document.getElementById('heatmap-data')?.getAttribute('data-chartdata') || '[]')
}
chart.setOption(options)
// Handle resize
window.addEventListener("resize", () => {
chart.resize();
});
}
document.addEventListener('DOMContentLoaded', setupHeatmap);
// Re-initialize when Astro's view transitions occur, this provides fix for SPA navigation
document.addEventListener('astro:page-load', setupHeatmap);
</script>
<style>
.chart {
width: 100%;
height: 200px;
}
.hidden {
display: none;
}
</style>

View file

@ -0,0 +1,7 @@
---
title: 'Now'
description: 'This is a test page'
---
import HeatMap from "../../components/shortcodes/HeatMap.astro"
<HeatMap GithubUserName='GrassBlock1' ForgejoInfo={[{instance:"codeberg.org",username:"grassblock"}]} />

View file

@ -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
}

View file

@ -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]);
});
}

View file

@ -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;
}

9
src/plugins/wordcount.js Normal file
View file

@ -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);
};
}