feat: add wordcount heatmap
This commit is contained in:
parent
8773b9b0df
commit
b79e3e2e47
9 changed files with 351 additions and 1 deletions
|
@ -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 ]
|
||||
},
|
||||
|
||||
|
|
|
@ -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
34
pnpm-lock.yaml
generated
|
@ -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: {}
|
||||
|
|
155
src/components/shortcodes/HeatMap.astro
Normal file
155
src/components/shortcodes/HeatMap.astro
Normal 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>
|
7
src/content/pages/now.mdx
Normal file
7
src/content/pages/now.mdx
Normal 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"}]} />
|
17
src/plugins/heatmapdata/forgejo.js
Normal file
17
src/plugins/heatmapdata/forgejo.js
Normal 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
|
||||
}
|
94
src/plugins/heatmapdata/github.js
Normal file
94
src/plugins/heatmapdata/github.js
Normal 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]);
|
||||
});
|
||||
}
|
30
src/plugins/heatmapdata/local.js
Normal file
30
src/plugins/heatmapdata/local.js
Normal 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
9
src/plugins/wordcount.js
Normal 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);
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue