Update blog metrics; add vercel imsights

This commit is contained in:
2026-03-31 14:03:41 -07:00
parent 99e4e65d92
commit adc1f21204
14 changed files with 514 additions and 95 deletions

View File

@@ -18,14 +18,17 @@
},
"dependencies": {
"@astrojs/mdx": "^5.0.3",
"@astrojs/vercel": "^10.0.3",
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/vercel": "^10.0.3",
"@giscus/react": "^3.1.0",
"@pilcrowjs/object-parser": "^0.0.4",
"@react-hook/intersection-observer": "^3.1.2",
"@rehype-pretty/transformers": "^0.13.2",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"arctic": "^3.7.0",
"ioredis": "^5.10.1",
"lucide-react": "^0.468.0",
"marked": "^15.0.12",
"react": "^18.3.1",

183
pnpm-lock.yaml generated
View File

@@ -10,7 +10,7 @@ importers:
dependencies:
'@astrojs/mdx':
specifier: ^5.0.3
version: 5.0.3(astro@6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))
version: 5.0.3(astro@6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))
'@astrojs/rss':
specifier: ^4.0.18
version: 4.0.18
@@ -19,7 +19,7 @@ importers:
version: 3.7.2
'@astrojs/vercel':
specifier: ^10.0.3
version: 10.0.3(astro@6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))(react@18.3.1)(rollup@4.60.1)
version: 10.0.3(astro@6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))(react@18.3.1)(rollup@4.60.1)
'@giscus/react':
specifier: ^3.1.0
version: 3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -32,9 +32,18 @@ importers:
'@rehype-pretty/transformers':
specifier: ^0.13.2
version: 0.13.2
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1(react@18.3.1)
'@vercel/speed-insights':
specifier: ^2.0.0
version: 2.0.0(react@18.3.1)
arctic:
specifier: ^3.7.0
version: 3.7.0
ioredis:
specifier: ^5.10.1
version: 5.10.1
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@18.3.1)
@@ -80,7 +89,7 @@ importers:
version: 5.0.2(@types/node@24.12.0)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(jiti@1.21.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(yaml@2.8.3)
'@astrojs/tailwind':
specifier: ^6.0.2
version: 6.0.2(astro@6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))(tailwindcss@3.4.19(yaml@2.8.3))
version: 6.0.2(astro@6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))(tailwindcss@3.4.19(yaml@2.8.3))
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@3.4.19(yaml@2.8.3))
@@ -92,7 +101,7 @@ importers:
version: 18.3.7(@types/react@18.3.28)
astro:
specifier: ^6.1.2
version: 6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3)
version: 6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3)
tailwindcss:
specifier: ^3.4.19
version: 3.4.19(yaml@2.8.3)
@@ -563,6 +572,9 @@ packages:
cpu: [x64]
os: [win32]
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
@@ -913,6 +925,9 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
'@upstash/redis@1.37.0':
resolution: {integrity: sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==}
'@vercel/analytics@1.6.1':
resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==}
peerDependencies:
@@ -939,6 +954,35 @@ packages:
vue-router:
optional: true
'@vercel/analytics@2.0.1':
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
peerDependencies:
'@remix-run/react': ^2
'@sveltejs/kit': ^1 || ^2
next: '>= 13'
nuxt: '>= 3'
react: ^18 || ^19 || ^19.0.0-rc
svelte: '>= 4'
vue: ^3
vue-router: ^4
peerDependenciesMeta:
'@remix-run/react':
optional: true
'@sveltejs/kit':
optional: true
next:
optional: true
nuxt:
optional: true
react:
optional: true
svelte:
optional: true
vue:
optional: true
vue-router:
optional: true
'@vercel/functions@3.4.3':
resolution: {integrity: sha512-kA14KIUVgAY6VXbhZ5jjY+s0883cV3cZqIU3WhrSRxuJ9KvxatMjtmzl0K23HK59oOUjYl7HaE/eYMmhmqpZzw==}
engines: {node: '>= 20'}
@@ -948,6 +992,11 @@ packages:
'@aws-sdk/credential-provider-web-identity':
optional: true
'@vercel/kv@3.0.0':
resolution: {integrity: sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==}
engines: {node: '>=14.6'}
deprecated: 'Vercel KV is deprecated. If you had an existing KV store, it should have moved to Upstash Redis which you will see under Vercel Integrations. For new projects, install a Redis integration from Vercel Marketplace: https://vercel.com/marketplace?category=storage&search=redis'
'@vercel/nft@1.5.0':
resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==}
engines: {node: '>=20'}
@@ -960,6 +1009,32 @@ packages:
'@vercel/routing-utils@5.3.3':
resolution: {integrity: sha512-KYm2sLNUD48gDScv8ob4ejc3Gww2jcJyW80hTdYlenAPz/5BQar1Gyh38xrUuZ532TUwSb5mV1uRbAuiykq0EQ==}
'@vercel/speed-insights@2.0.0':
resolution: {integrity: sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==}
peerDependencies:
'@sveltejs/kit': ^1 || ^2
next: '>= 13'
nuxt: '>= 3'
react: ^18 || ^19 || ^19.0.0-rc
svelte: '>= 4'
vue: ^3
vue-router: ^4
peerDependenciesMeta:
'@sveltejs/kit':
optional: true
next:
optional: true
nuxt:
optional: true
react:
optional: true
svelte:
optional: true
vue:
optional: true
vue-router:
optional: true
'@vitejs/plugin-react@5.2.0':
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1115,6 +1190,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
collapse-white-space@2.1.0:
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
@@ -1195,6 +1274,10 @@ packages:
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -1457,6 +1540,10 @@ packages:
resolution: {integrity: sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==}
deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.
ioredis@5.10.1:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'}
iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
@@ -1550,6 +1637,12 @@ packages:
lit@3.3.2:
resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -2061,6 +2154,14 @@ packages:
recma-stringify@1.0.0:
resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
@@ -2202,6 +2303,9 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
stream-replace-string@2.0.0:
resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==}
@@ -2559,12 +2663,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@astrojs/mdx@5.0.3(astro@6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))':
'@astrojs/mdx@5.0.3(astro@6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))':
dependencies:
'@astrojs/markdown-remark': 7.1.0
'@mdx-js/mdx': 3.1.1
acorn: 8.16.0
astro: 6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3)
astro: 6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3)
es-module-lexer: 2.0.0
estree-util-visit: 2.0.0
hast-util-to-html: 9.0.5
@@ -2619,9 +2723,9 @@ snapshots:
stream-replace-string: 2.0.0
zod: 4.3.6
'@astrojs/tailwind@6.0.2(astro@6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))(tailwindcss@3.4.19(yaml@2.8.3))':
'@astrojs/tailwind@6.0.2(astro@6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))(tailwindcss@3.4.19(yaml@2.8.3))':
dependencies:
astro: 6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3)
astro: 6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
@@ -2641,14 +2745,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@astrojs/vercel@10.0.3(astro@6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))(react@18.3.1)(rollup@4.60.1)':
'@astrojs/vercel@10.0.3(astro@6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3))(react@18.3.1)(rollup@4.60.1)':
dependencies:
'@astrojs/internal-helpers': 0.8.0
'@vercel/analytics': 1.6.1(react@18.3.1)
'@vercel/functions': 3.4.3
'@vercel/nft': 1.5.0(rollup@4.60.1)
'@vercel/routing-utils': 5.3.3
astro: 6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3)
astro: 6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3)
esbuild: 0.27.4
tinyglobby: 0.2.15
transitivePeerDependencies:
@@ -2975,6 +3079,8 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@ioredis/commands@1.5.1': {}
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
@@ -3327,14 +3433,28 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@upstash/redis@1.37.0':
dependencies:
uncrypto: 0.1.3
optional: true
'@vercel/analytics@1.6.1(react@18.3.1)':
optionalDependencies:
react: 18.3.1
'@vercel/analytics@2.0.1(react@18.3.1)':
optionalDependencies:
react: 18.3.1
'@vercel/functions@3.4.3':
dependencies:
'@vercel/oidc': 3.2.0
'@vercel/kv@3.0.0':
dependencies:
'@upstash/redis': 1.37.0
optional: true
'@vercel/nft@1.5.0(rollup@4.60.1)':
dependencies:
'@mapbox/node-pre-gyp': 2.0.3
@@ -3363,6 +3483,10 @@ snapshots:
optionalDependencies:
ajv: 6.14.0
'@vercel/speed-insights@2.0.0(react@18.3.1)':
optionalDependencies:
react: 18.3.1
'@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(yaml@2.8.3))':
dependencies:
'@babel/core': 7.29.0
@@ -3420,7 +3544,7 @@ snapshots:
astring@1.9.0: {}
astro@6.1.2(@types/node@24.12.0)(@vercel/functions@3.4.3)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3):
astro@6.1.2(@types/node@24.12.0)(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)(jiti@1.21.7)(rollup@4.60.1)(typescript@5.7.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 3.0.1
'@astrojs/internal-helpers': 0.8.0
@@ -3470,7 +3594,7 @@ snapshots:
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@vercel/functions@3.4.3)
unstorage: 1.17.5(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 7.3.1(@types/node@24.12.0)(jiti@1.21.7)(yaml@2.8.3)
vitefu: 1.1.2(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(yaml@2.8.3))
@@ -3593,6 +3717,8 @@ snapshots:
clsx@2.1.1: {}
cluster-key-slot@1.1.2: {}
collapse-white-space@2.1.0: {}
comma-separated-tokens@2.0.3: {}
@@ -3655,6 +3781,8 @@ snapshots:
defu@6.1.4: {}
denque@2.1.0: {}
dequal@2.0.3: {}
destr@2.0.5: {}
@@ -4037,6 +4165,20 @@ snapshots:
intersection-observer@0.10.0: {}
ioredis@5.10.1:
dependencies:
'@ioredis/commands': 1.5.1
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
iron-webcrypto@1.2.1: {}
is-alphabetical@2.0.1: {}
@@ -4113,6 +4255,10 @@ snapshots:
lit-element: 4.2.2
lit-html: 3.3.2
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@@ -4868,6 +5014,12 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
@@ -5132,6 +5284,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
standard-as-callback@2.1.0: {}
stream-replace-string@2.0.0: {}
stringify-entities@4.0.4:
@@ -5325,7 +5479,7 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
unstorage@1.17.5(@vercel/functions@3.4.3):
unstorage@1.17.5(@upstash/redis@1.37.0)(@vercel/functions@3.4.3)(@vercel/kv@3.0.0)(ioredis@5.10.1):
dependencies:
anymatch: 3.1.3
chokidar: 5.0.0
@@ -5336,7 +5490,10 @@ snapshots:
ofetch: 1.5.1
ufo: 1.6.3
optionalDependencies:
'@upstash/redis': 1.37.0
'@vercel/functions': 3.4.3
'@vercel/kv': 3.0.0
ioredis: 5.10.1
update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:

View File

@@ -0,0 +1,11 @@
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/react";
export default function VercelAnalytics() {
return (
<>
<Analytics />
<SpeedInsights />
</>
);
}

View File

@@ -77,7 +77,7 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
className="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
onClick={(e) => {
e.preventDefault();
window.location.href = `/blog/tag/${tag}`;
window.location.href = `/blog/tags/${encodeURIComponent(tag)}`;
}}
>
#{tag}

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import { AnimateIn } from "@/components/animate-in";
interface BlogPost {
title: string;
@@ -11,47 +12,49 @@ interface TagListProps {
posts: BlogPost[];
}
const TagList: React.FC<TagListProps> = ({ posts }) => {
const spectrumColors = [
'text-red-bright',
'text-orange-bright',
'text-yellow-bright',
'text-green-bright',
'text-aqua-bright',
'text-blue-bright',
'text-purple-bright'
];
const spectrumColors = [
'text-red-bright',
'text-orange-bright',
'text-yellow-bright',
'text-green-bright',
'text-aqua-bright',
'text-blue-bright',
'text-purple-bright'
];
const sizeClasses = [
'text-3xl sm:text-4xl',
'text-2xl sm:text-3xl',
'text-xl sm:text-2xl',
'text-lg sm:text-xl',
'text-base sm:text-lg',
];
const TagList = ({ posts }: TagListProps) => {
const tagData = useMemo(() => {
if (!Array.isArray(posts)) return [];
const tagMap = new Map();
const tagMap = new Map<string, number>();
posts.forEach(post => {
if (post?.data?.tags && Array.isArray(post.data.tags)) {
post.data.tags.forEach(tag => {
if (!tagMap.has(tag)) {
tagMap.set(tag, {
name: tag,
count: 1
});
} else {
const data = tagMap.get(tag);
data.count++;
}
});
}
post?.data?.tags?.forEach(tag => {
tagMap.set(tag, (tagMap.get(tag) || 0) + 1);
});
});
const tagArray = Array.from(tagMap.values());
const maxCount = Math.max(...tagArray.map(t => t.count));
return tagArray
.sort((a, b) => b.count - a.count)
.map((tag, index) => ({
...tag,
color: spectrumColors[index % spectrumColors.length],
frequency: tag.count / maxCount
}));
const tags = Array.from(tagMap.entries())
.sort((a, b) => b[1] - a[1]);
const maxCount = tags[0]?.[1] || 1;
return tags.map(([name, count], i) => {
const ratio = count / maxCount;
const sizeIndex = ratio > 0.8 ? 0 : ratio > 0.6 ? 1 : ratio > 0.4 ? 2 : ratio > 0.2 ? 3 : 4;
return {
name,
count,
color: spectrumColors[i % spectrumColors.length],
size: sizeClasses[sizeIndex],
};
});
}, [posts]);
if (tagData.length === 0) {
@@ -63,50 +66,25 @@ const TagList: React.FC<TagListProps> = ({ posts }) => {
}
return (
<div className="flex-1 w-full bg-background p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{tagData.map(({ name, count, color, frequency }) => (
<div className="flex flex-wrap items-baseline justify-center gap-x-6 gap-y-4 sm:gap-x-8 sm:gap-y-5 px-4 py-8 max-w-4xl mx-auto">
{tagData.map(({ name, count, color, size }, i) => (
<AnimateIn key={name} delay={i * 50}>
<a
key={name}
href={`/blog/tags/${encodeURIComponent(name)}`}
className={`
group relative
flex flex-col items-center justify-center
min-h-[5rem]
px-6 py-4 rounded-lg
text-xl
transition-all duration-300 ease-in-out
hover:scale-105
hover:bg-foreground/5
${color}
${color} ${size}
font-medium
hover:opacity-70 transition-opacity duration-200
cursor-pointer whitespace-nowrap
`}
>
{/* Main tag display */}
<div className="font-medium text-center">
#{name}
</div>
{/* Post count */}
<div className="mt-2 text-base opacity-60">
{count} post{count !== 1 ? 's' : ''}
</div>
{/* Background gradient */}
<div
className="absolute inset-0 -z-10 rounded-lg opacity-10"
style={{
background: `
linear-gradient(
45deg,
currentColor ${frequency * 100}%,
transparent
)
`
}}
/>
#{name}
<span className="text-foreground/30 text-xs ml-1 align-super">
{count}
</span>
</a>
))}
</div>
</AnimateIn>
))}
</div>
);
};

View File

@@ -0,0 +1,123 @@
import { AnimateIn } from "@/components/animate-in";
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
type BlogPost = {
id: string;
data: {
title: string;
author: string;
date: string;
tags: string[];
description: string;
image?: string;
imagePosition?: string;
};
};
interface TaggedPostsProps {
tag: string;
posts: BlogPost[];
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
};
const TaggedPosts = ({ tag, posts }: TaggedPostsProps) => {
return (
<div className="w-full max-w-6xl mx-auto">
<div className="w-full px-4 pt-24 sm:pt-24">
<AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed">
#{tag}
</h1>
</AnimateIn>
<AnimateIn delay={100}>
<div className="flex flex-wrap justify-center gap-4 mb-12 text-sm sm:text-base">
<a
href="/rss"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-orange hover:text-orange-bright hover:border-orange/50 transition-colors duration-200"
>
<RssIcon className="w-4 h-4" />
<span>RSS Feed</span>
</a>
<a
href="/blog/tags"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-aqua hover:text-aqua-bright hover:border-aqua/50 transition-colors duration-200"
>
<TagIcon className="w-4 h-4" />
<span>Browse Tags</span>
</a>
<a
href="/blog/popular"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-blue hover:text-blue-bright hover:border-blue/50 transition-colors duration-200"
>
<TrendingUpIcon className="w-4 h-4" />
<span>Most Popular</span>
</a>
</div>
</AnimateIn>
</div>
<ul className="space-y-6 md:space-y-10">
{posts.map((post, i) => (
<AnimateIn key={post.id} delay={200 + i * 80}>
<li className="group px-4 md:px-0">
<a href={`/blog/${post.id}`} className="block">
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200">
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0">
<img
src={post.data.image || "/blog/placeholder.png"}
alt={post.data.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
style={{ objectPosition: post.data.imagePosition || "center center" }}
/>
</div>
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
<div className="space-y-1.5 md:space-y-3">
<h2 className="text-lg md:text-2xl font-semibold text-yellow group-hover:text-purple transition-colors duration-200 line-clamp-2">
{post.data.title}
</h2>
<div className="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80">
<span className="text-orange">{post.data.author}</span>
<span className="text-foreground/50">&bull;</span>
<time dateTime={post.data.date} className="text-blue">
{formatDate(post.data.date)}
</time>
</div>
</div>
<p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2 mt-0.5 md:mt-0">
{post.data.description}
</p>
<div className="flex flex-wrap gap-1.5 md:gap-3 mt-1 md:mt-2">
{post.data.tags.map((t) => (
<span
key={t}
className={`text-xs md:text-base transition-colors duration-200 ${
t === tag ? "text-aqua-bright" : "text-aqua hover:text-aqua-bright"
}`}
onClick={(e) => {
e.preventDefault();
window.location.href = `/blog/tags/${encodeURIComponent(t)}`;
}}
>
#{t}
</span>
))}
</div>
</div>
</article>
</a>
</li>
</AnimateIn>
))}
</ul>
</div>
);
};
export default TaggedPosts;

View File

@@ -7,6 +7,7 @@ import Footer from "@/components/footer";
import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -68,6 +69,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
</div>
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>

View File

@@ -8,6 +8,7 @@ import Footer from "@/components/footer";
import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -48,6 +49,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<Footer client:load transition:persist fixed=true />
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load />
<script is:inline set:html={THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>

View File

@@ -7,6 +7,7 @@ import Footer from "@/components/footer";
import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -64,6 +65,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
</main>
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>

55
src/lib/views.ts Normal file
View File

@@ -0,0 +1,55 @@
import Redis from "ioredis";
let redis: Redis | null = null;
function getRedis(): Redis | null {
if (redis) return redis;
const url = import.meta.env.REDIS_URL || process.env.REDIS_URL;
if (!url) return null;
redis = new Redis(url);
return redis;
}
export async function incrementViews(slug: string): Promise<number> {
const r = getRedis();
if (!r) return 0;
try {
return await r.incr(`views:${slug}`);
} catch {
return 0;
}
}
export async function getViews(slug: string): Promise<number> {
const r = getRedis();
if (!r) return 0;
try {
const val = await r.get(`views:${slug}`);
return val ? parseInt(val, 10) : 0;
} catch {
return 0;
}
}
export async function getAllViews(slugs: string[]): Promise<Record<string, number>> {
const r = getRedis();
const result: Record<string, number> = {};
if (!r || slugs.length === 0) return result;
try {
const keys = slugs.map(s => `views:${s}`);
const values = await r.mget(...keys);
for (let i = 0; i < slugs.length; i++) {
result[slugs[i]] = values[i] ? parseInt(values[i], 10) : 0;
}
} catch {
// Return empty counts if Redis unavailable
}
return result;
}

View File

@@ -5,6 +5,7 @@ import ContentLayout from "@/layouts/content.astro";
import { getArticleSchema } from "@/lib/structuredData";
import { blogWebsite } from "@/lib/structuredData";
import { Comments } from "@/components/blog/comments";
import { incrementViews, getViews } from "@/lib/views";
// This is a dynamic route in SSR mode
const { slug } = Astro.params;
@@ -20,6 +21,14 @@ if (!post || (!import.meta.env.DEV && post.data.isDraft === true)) {
});
}
// Track page view and get count
let views = 0;
if (!import.meta.env.DEV) {
views = await incrementViews(post.id);
} else {
views = await getViews(post.id);
}
// Dynamically render the content
const { Content } = await render(post);
@@ -84,12 +93,18 @@ const jsonLd = {
<time dateTime={post.data.date instanceof Date ? post.data.date.toISOString() : post.data.date} class="text-blue">
{formattedDate}
</time>
{views > 0 && (
<>
<span class="text-foreground/50">•</span>
<span class="text-green">{views.toLocaleString()} view{views !== 1 ? "s" : ""}</span>
</>
)}
</div>
<div class="flex flex-wrap gap-2 mt-2">
{post.data.tags.map((tag) => (
<span
class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
onclick={`window.location.href='/blog/tag/${tag}'`}
onclick={`window.location.href='/blog/tags/${encodeURIComponent(tag)}'`}
>
#{tag}
</span>

View File

@@ -0,0 +1,32 @@
---
import { getCollection } from "astro:content";
import ContentLayout from "@/layouts/content.astro";
import { BlogHeader } from "@/components/blog/header";
import { BlogPostList } from "@/components/blog/post-list";
import { getAllViews } from "@/lib/views";
const posts = (await getCollection("blog", ({ data }) => {
return import.meta.env.DEV || data.isDraft !== true;
})).map(post => ({
...post,
data: {
...post.data,
date: post.data.date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
})
}
}));
// Get view counts and sort by popularity
const views = await getAllViews(posts.map(p => p.id));
const sorted = [...posts].sort((a, b) => (views[b.id] || 0) - (views[a.id] || 0));
---
<ContentLayout
title="Most Popular | Blog | Timothy Pidashev"
description="Most popular blog posts by view count."
>
<BlogHeader client:load />
<BlogPostList posts={sorted} client:load />
</ContentLayout>

View File

@@ -0,0 +1,38 @@
---
import { getCollection } from "astro:content";
import ContentLayout from "@/layouts/content.astro";
import TaggedPosts from "@/components/blog/tagged-posts";
const { slug } = Astro.params;
const tag = decodeURIComponent(slug || "");
if (!tag) {
return Astro.redirect("/blog/tags");
}
const filteredPosts = (await getCollection("blog", ({ data }) => {
return (import.meta.env.DEV || data.isDraft !== true) && data.tags.includes(tag);
})).sort((a, b) => {
return b.data.date.valueOf() - a.data.date.valueOf();
}).map(post => ({
...post,
data: {
...post.data,
date: post.data.date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
})
}
}));
if (filteredPosts.length === 0) {
return Astro.redirect("/blog/tags");
}
---
<ContentLayout
title={`#${tag} | Blog | Timothy Pidashev`}
description={`Blog posts tagged with "${tag}".`}
>
<TaggedPosts tag={tag} posts={filteredPosts} client:load />
</ContentLayout>

View File

@@ -1,7 +1,7 @@
---
import { getCollection } from "astro:content";
import ContentLayout from "@/layouts/content.astro";
import { BlogHeader } from "@/components/blog/header";
import TagList from "@/components/blog/tag-list";
const posts = (await getCollection("blog", ({ data }) => {
@@ -20,9 +20,10 @@ const posts = (await getCollection("blog", ({ data }) => {
}
}));
---
<ContentLayout
title="Blog | Timothy Pidashev"
description="My experiences and technical insights into software development and the ever-evolving world of programming."
<ContentLayout
title="Browse Tags | Blog | Timothy Pidashev"
description="Browse blog posts by tag."
>
<TagList posts={posts} />
<BlogHeader client:load />
<TagList posts={posts} client:load />
</ContentLayout>