This commit is contained in:
15
site/.env.example
Normal file
15
site/.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Public site base URL used for canonical URLs (no trailing slash)
|
||||
PUBLIC_SITE_URL=https://example.com
|
||||
|
||||
# Umami (optional). If not set, analytics is disabled.
|
||||
PUBLIC_UMAMI_SCRIPT_URL=https://analytics.example.com/script.js
|
||||
PUBLIC_UMAMI_WEBSITE_ID=00000000-0000-0000-0000-000000000000
|
||||
|
||||
# Content ingestion configuration (used by scripts)
|
||||
YOUTUBE_CHANNEL_ID=UCxxxxxxxxxxxxxxxxxxxxxx
|
||||
YOUTUBE_API_KEY=
|
||||
PODCAST_RSS_URL=https://example.com/podcast.rss
|
||||
|
||||
# Instagram embed-first list (JSON file containing {"postUrls":[...]})
|
||||
INSTAGRAM_POST_URLS_FILE=content/instagram-posts.json
|
||||
|
||||
24
site/.gitignore
vendored
Normal file
24
site/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
5
site/.prettierignore
Normal file
5
site/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
package-lock.json
|
||||
|
||||
4
site/.vscode/extensions.json
vendored
Normal file
4
site/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
site/.vscode/launch.json
vendored
Normal file
11
site/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
43
site/README.md
Normal file
43
site/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
9
site/astro.config.mjs
Normal file
9
site/astro.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from "astro/config";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: process.env.PUBLIC_SITE_URL || "http://localhost:4321",
|
||||
integrations: [sitemap()],
|
||||
});
|
||||
1
site/content/cache/.gitkeep
vendored
Normal file
1
site/content/cache/.gitkeep
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
680
site/content/cache/content.json
vendored
Normal file
680
site/content/cache/content.json
vendored
Normal file
@@ -0,0 +1,680 @@
|
||||
{
|
||||
"generatedAt": "2026-02-10T04:14:37.396Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "gPGbtfQdaw4",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=gPGbtfQdaw4",
|
||||
"title": "AI Agents Are Hiring HUMANS Now? RentAHuman.ai Explained",
|
||||
"publishedAt": "2026-02-08T19:57:08.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/gPGbtfQdaw4/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 41
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "aesTuu2nS-I",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=aesTuu2nS-I",
|
||||
"title": "I will not die. Not today!!",
|
||||
"publishedAt": "2026-02-05T05:53:25.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/aesTuu2nS-I/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 147
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "9t8cBpZLHUo",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=9t8cBpZLHUo",
|
||||
"title": "I Can’t Believe This Exists: ThePrimeagen’s Terminal.shop is INSANE",
|
||||
"publishedAt": "2026-02-05T04:31:18.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/9t8cBpZLHUo/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 325
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "71S5viSJG20",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=71S5viSJG20",
|
||||
"title": "Is This Real Life? ✈️ Ultra 4K Flight Over Europe’s Most Iconic Cities",
|
||||
"publishedAt": "2026-01-29T13:54:28.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/71S5viSJG20/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 49
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "SO-tjsB4ZJs",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=SO-tjsB4ZJs",
|
||||
"title": "16 OSCAR NOMINATIONS?! 🏆 This movie is Next Level.",
|
||||
"publishedAt": "2026-01-28T06:30:50.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/SO-tjsB4ZJs/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 470
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "FV30wjF1WQ4",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=FV30wjF1WQ4",
|
||||
"title": "Can We Survive Winter Storm Fern? ❄️ (Northeast US Live Weather) ✈️ | MSFS 2020 (No Commentary)",
|
||||
"publishedAt": "2026-01-26T13:12:12.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/FV30wjF1WQ4/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 32
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "_1-albWBfoc",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=_1-albWBfoc",
|
||||
"title": "#Psychological #thriller #movie from #Indonesia",
|
||||
"publishedAt": "2026-01-25T16:55:51.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/_1-albWBfoc/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 101
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ts-DWD8F68Q",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=ts-DWD8F68Q",
|
||||
"title": "Sleepless Skies: Finding Calm Above the Clouds ✈️ | MSFS 2024 (No Commentary)",
|
||||
"publishedAt": "2026-01-24T18:12:01.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/ts-DWD8F68Q/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 35
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nlbkGnznzA8",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=nlbkGnznzA8",
|
||||
"title": "A #movie that will creep you out! #survival",
|
||||
"publishedAt": "2026-01-22T02:21:01.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/nlbkGnznzA8/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 358
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "zR9Ey8DjG5s",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=zR9Ey8DjG5s",
|
||||
"title": "A Tour of the World’s Most Iconic Cities 🌍✨✈️ | MSFS 2020",
|
||||
"publishedAt": "2026-01-20T17:02:14.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/zR9Ey8DjG5s/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 24
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "oerSPWeIy5k",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=oerSPWeIy5k",
|
||||
"title": "🎥 Dive into the quirky world of Kumiko",
|
||||
"publishedAt": "2026-01-20T03:01:02.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/oerSPWeIy5k/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 75
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fzw7GUszgdQ",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=fzw7GUszgdQ",
|
||||
"title": "RUSTY DRIVER RETURNS 🏎️ F1 25 Short Season Practice (Logitech G920)",
|
||||
"publishedAt": "2026-01-18T17:36:36.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/fzw7GUszgdQ/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 54
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dlIADQOfXlQ",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=dlIADQOfXlQ",
|
||||
"title": "Sleepless Skies: Finding Calm Above the Clouds ✈️ | MSFS 2020 (No Commentary)",
|
||||
"publishedAt": "2026-01-16T18:00:30.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/dlIADQOfXlQ/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0-AX9KaJUSg",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=0-AX9KaJUSg",
|
||||
"title": "💸 Your Daily Reset 🧘 Cities: Skylines | Billionaire Paradise Build | Lofi",
|
||||
"publishedAt": "2026-01-16T01:07:06.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/0-AX9KaJUSg/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "xiSka36EF5c",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=xiSka36EF5c",
|
||||
"title": "Episode 41a (Recap) - US History Podcast Catch‑Up: From Colonization to the Early Civil Rights Mo...",
|
||||
"publishedAt": "2026-01-15T16:58:14.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/xiSka36EF5c/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "oBXH9VhnZCs",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=oBXH9VhnZCs",
|
||||
"title": "No Mic, Just Silk: Finding My Way in Silksong | Stress-Busting Stream",
|
||||
"publishedAt": "2026-01-14T16:17:29.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/oBXH9VhnZCs/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "IYxjSZStHJ0",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=IYxjSZStHJ0",
|
||||
"title": "STRESS BUSTING | Trying out some new games!",
|
||||
"publishedAt": "2026-01-14T14:22:54.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/IYxjSZStHJ0/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 55
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "aAufRPAScCE",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=aAufRPAScCE",
|
||||
"title": "STRESS BUSTING | Trying out some new games!",
|
||||
"publishedAt": "2026-01-14T01:26:33.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/aAufRPAScCE/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dY2tlGRaUj4",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=dY2tlGRaUj4",
|
||||
"title": "Lofi Loops & Legends: Flying Iconic US Cities 🇺🇸 | MSFS 2020",
|
||||
"publishedAt": "2026-01-13T20:42:25.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/dY2tlGRaUj4/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 36
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mj5-oTVJ0AU",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=mj5-oTVJ0AU",
|
||||
"title": "America from Above: Iconic Landmarks & Relaxing Lofi Vibes | MSFS 2020",
|
||||
"publishedAt": "2026-01-13T16:35:38.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/mj5-oTVJ0AU/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ULtQuR1tsOg",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=ULtQuR1tsOg",
|
||||
"title": "Lofi Loops & Legends: Flying Iconic US Cities 🇺🇸 | MSFS 2020",
|
||||
"publishedAt": "2026-01-13T01:56:35.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/ULtQuR1tsOg/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 103
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "zaQyRCujqk4",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=zaQyRCujqk4",
|
||||
"title": "No More Red Roads! 🛑 Fixing Yesterday’s Traffic Mess in Cities: Skylines",
|
||||
"publishedAt": "2026-01-12T10:26:54.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/zaQyRCujqk4/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "HQWyqb4I0Bo",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=HQWyqb4I0Bo",
|
||||
"title": "A mind bending movie waiting for you!! #bugonia #emmastone #andromeda",
|
||||
"publishedAt": "2026-01-11T23:53:54.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/HQWyqb4I0Bo/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 1223
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m5OEtszkSyA",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=m5OEtszkSyA",
|
||||
"title": "Can an Introvert Build the Perfect City? | Cities: Skylines Zen Stream",
|
||||
"publishedAt": "2026-01-11T22:18:08.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/m5OEtszkSyA/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 31
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "XsJCIeqFWCY",
|
||||
"source": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=XsJCIeqFWCY",
|
||||
"title": "🚧 ZERO Traffic Jams in Cities: Skylines? My Impossible Build!",
|
||||
"publishedAt": "2026-01-11T22:15:03.000Z",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/XsJCIeqFWCY/hqdefault.jpg",
|
||||
"metrics": {
|
||||
"views": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2dad79d8-ed54-42a8-91a1-4c12a22e3070",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E43--US-History--Understanding-This-Country--Childrens-Crusade--the-Civil-Rights-Act-of-1964-Turning-Protest-into-Law-e3e3t94",
|
||||
"title": "E43. US History – Understanding This Country | Children's Crusade & the Civil Rights Act of 1964: Turning Protest into Law",
|
||||
"publishedAt": "2026-01-24T03:17:34.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "90b42ea8-16e9-4374-8043-858c5c04db3f",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E42--US-History--Understanding-This-Country--Civil-Rights-Movement-from-Courtrooms-to-the-Streets-e3doqu8",
|
||||
"title": "E42. US History – Understanding This Country | Civil Rights Movement from Courtrooms to the Streets",
|
||||
"publishedAt": "2026-01-16T19:59:44.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "9900f1c9-315b-42ba-991f-c31241bd9b55",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Episode-41a-Recap---US-History-Podcast-CatchUp-From-Colonization-to-the-Early-Civil-Rights-Movement-e3dmuli",
|
||||
"title": "Episode 41a (Recap) - US History Podcast Catch‑Up: From Colonization to the Early Civil Rights Movement",
|
||||
"publishedAt": "2026-01-15T16:24:16.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "9002e7f6-8ecf-47e2-9590-ba4adfc5dc6d",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E41--US-History--Understanding-This-Country--Civil-Rights-Beginnings-Brown--Parks--King-e38fi0r",
|
||||
"title": "E41. US History – Understanding This Country | Civil Rights Beginnings: Brown, Parks & King",
|
||||
"publishedAt": "2025-09-20T03:47:37.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "96e72f96-fb71-4717-9047-120c6e32973a",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E40--US-History--Understanding-This-Country--Prosperity--TV--Rock-n-Roll-e387ogh",
|
||||
"title": "E40. US History – Understanding This Country | Prosperity, TV, Rock ’n’ Roll",
|
||||
"publishedAt": "2025-09-15T02:28:58.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "bb4faa6e-384d-4204-b133-438c5a82aefd",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E39--US-History--Understanding-This-Country--The-Korean-War-Americas-First-Test-of-the-Cold-War-e37t0f5",
|
||||
"title": "E39. US History – Understanding This Country | The Korean War: America’s First Test of the Cold War",
|
||||
"publishedAt": "2025-09-07T04:58:14.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "2d56a5a0-948d-446f-b05d-f18f4299530f",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E38--US-History--Understanding-This-Country--Victory-to-Cold-War-Tensions-e36tqu6",
|
||||
"title": "E38. US History – Understanding This Country | Victory to Cold War Tensions",
|
||||
"publishedAt": "2025-08-16T03:52:22.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "14912927-4ea3-4946-948a-dbeeacbd1535",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E37--US-History--Understanding-This-Country--From-D-Day-to-Nagasaki-e36ks7j",
|
||||
"title": "E37. US History – Understanding This Country | From D-Day to Nagasaki",
|
||||
"publishedAt": "2025-08-09T04:15:45.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "063ab850-90c1-4e42-af44-9a64eaa1bf72",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E36--US-History--Understanding-This-Country--The-World-at-War--Again-How-the-US-stepped-into-WWII-e36bkih",
|
||||
"title": "E36. US History – Understanding This Country | The World at War, Again: How the US stepped into WWII",
|
||||
"publishedAt": "2025-08-02T03:41:33.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "4d616ef1-46e4-46b5-8fa4-e3e71688d2c0",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E35--US-History--Understanding-This-Country--The-Great-Depression-e361hrl",
|
||||
"title": "E35. US History – Understanding This Country | The Great Depression",
|
||||
"publishedAt": "2025-07-26T02:32:26.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "2a5efb30-1749-4bcb-b54b-a83878a6015e",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E34--US-History--Understanding-This-Country--The-Roaring-Twenties-e35o9s0",
|
||||
"title": "E34. US History – Understanding This Country | The Roaring Twenties",
|
||||
"publishedAt": "2025-07-19T03:58:56.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "d41eeed2-96a0-4afe-a8e4-1cf30dec8988",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E33--US-History--Understanding-This-Country--The-Great-War-How-World-War-I-Transformed-America-and-the-World-e35eos4",
|
||||
"title": "E33. US History – Understanding This Country | The Great War: How World War I Transformed America and the World",
|
||||
"publishedAt": "2025-07-12T04:33:52.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "1fc5bbbf-e649-41b2-9145-85757cbee0a8",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E32--US-History--Understanding-This-Country--American-Muscle-and-Presidents-of-Power-e354i1u",
|
||||
"title": "E32. US History – Understanding This Country | American Muscle and Presidents of Power",
|
||||
"publishedAt": "2025-07-05T03:30:53.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "9d5a87a4-bdb7-474f-a249-a43ac6d477e6",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E31--US-History--Understanding-This-Country--Expansionism-and-Imperialism-e34r500",
|
||||
"title": "E31. US History – Understanding This Country | Expansionism and Imperialism",
|
||||
"publishedAt": "2025-06-28T03:30:57.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "6b47cd15-1f10-4175-a8d2-0863777022a7",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E30--US-History--Understanding-This-Country--Progressivism-in-America-e34es36",
|
||||
"title": "E30. US History – Understanding This Country | Progressivism in America",
|
||||
"publishedAt": "2025-06-19T03:42:14.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "f73008f3-78d6-4644-975f-f5d00d21f404",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E29--US-History--Understanding-This-Country--Immigration-and-New-Cities-e347o88",
|
||||
"title": "E29. US History – Understanding This Country | Immigration and New Cities",
|
||||
"publishedAt": "2025-06-14T03:58:41.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "c1c4778d-2597-4b8a-86e2-dd2e5adfe526",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E28--US-History--Understanding-This-Country--Second-Industrial-Revolution-and-The-Age-of-Capitalism-e33togi",
|
||||
"title": "E28. US History – Understanding This Country | Second Industrial Revolution and The Age of Capitalism",
|
||||
"publishedAt": "2025-06-07T03:51:30.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "13359365-5be2-4f7c-ae11-94d412be7561",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E27--US-History--Understanding-This-Country--Clash-of-Cultures-e33josn",
|
||||
"title": "E27. US History – Understanding This Country | Clash of Cultures",
|
||||
"publishedAt": "2025-05-31T04:14:46.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "f7c449ee-f274-4944-9370-0d0c000a053c",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E26--US-History--Understanding-This-Country--Trains--Bonanzas-and-Cowboys-e339m68",
|
||||
"title": "E26. US History – Understanding This Country | Trains, Bonanzas and Cowboys",
|
||||
"publishedAt": "2025-05-24T03:30:17.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "b3edb562-64be-4902-a503-f9a93719caf9",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E25--US-History--Understanding-This-Country--Reconstruction-Redefining-Freedom-e32v9a2",
|
||||
"title": "E25. US History – Understanding This Country | Reconstruction: Redefining Freedom",
|
||||
"publishedAt": "2025-05-17T04:35:58.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "bd1cb52f-29fc-456a-b971-13030def86eb",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E24--US-History--Understanding-This-Country--The-Civil-War-e32l3na",
|
||||
"title": "E24. US History – Understanding This Country | The Civil War",
|
||||
"publishedAt": "2025-05-10T04:07:23.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "086d2da0-57b4-4410-8637-f076b19bdaf9",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E23--US-History--Understanding-This-Country--Prelude-to-Civil-War-e32al0g",
|
||||
"title": "E23. US History – Understanding This Country | Prelude to Civil War",
|
||||
"publishedAt": "2025-05-03T03:30:33.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "b22a7a44-8028-4c84-9dd6-31e7a62a6def",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E22--US-History--Understanding-This-Country--The-Age-of-Reform-e3217aj",
|
||||
"title": "E22. US History – Understanding This Country | The Age of Reform",
|
||||
"publishedAt": "2025-04-26T03:38:18.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "035b8239-d954-42a8-a1a5-d4fce21ff15a",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E21--US-History--Understanding-This-Country--The-Lone-Star-and-the-Borderlands-e31nkn1",
|
||||
"title": "E21. US History – Understanding This Country | The Lone Star and the Borderlands",
|
||||
"publishedAt": "2025-04-19T03:26:55.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "92f62156-44ed-4757-b46b-7b8d3b0deb00",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E20--US-History--Understanding-This-Country--The-Oregon-Trail-e31eh44",
|
||||
"title": "E20. US History – Understanding This Country | The Oregon Trail",
|
||||
"publishedAt": "2025-04-12T04:39:58.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "3fef0da4-41a1-4f69-a444-30a846ba6817",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E19--US-History--Understanding-This-Country--The-Wild-West-Journeys-e3139gh",
|
||||
"title": "E19. US History – Understanding This Country | The Wild West Journeys",
|
||||
"publishedAt": "2025-04-04T23:00:00.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "564c1115-f9ac-4c18-8e6f-2e805db638e2",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E18--US-History--Understanding-This-Country--From-Corrupt-Bargains-to-the-Trail-of-Tears-e30ql0c",
|
||||
"title": "E18. US History – Understanding This Country | From Corrupt Bargains to the Trail of Tears",
|
||||
"publishedAt": "2025-03-29T04:10:39.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "3fcbc28c-728f-42cf-83cd-812ba49db367",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E17--US-History--Understanding-This-Country--The-Era-of-Good-Feelings--Nationalism--Industry--Division-in-Early-America-e30fabn",
|
||||
"title": "E17. US History – Understanding This Country | The Era of Good Feelings? Nationalism, Industry & Division in Early America",
|
||||
"publishedAt": "2025-03-22T01:00:00.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "dbf4251d-3b67-4e98-be85-d4888aac4357",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E16--US-History--Understanding-This-Country--The-War-of-1812-and-Birth-of-the-Star-Spangled-Banner-e300cll",
|
||||
"title": "E16. US History – Understanding This Country | The War of 1812 and Birth of the Star Spangled Banner",
|
||||
"publishedAt": "2025-03-15T01:00:00.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "87f6b19f-b74a-4345-929e-084dec7236b5",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E15--US-History---Understanding-this-Country--Expansion--Power--and-Contradictions-e2vsbq8",
|
||||
"title": "E15. US History - Understanding this Country | Expansion, Power, and Contradictions",
|
||||
"publishedAt": "2025-03-08T05:43:34.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "0bb43b9e-c1bc-40be-b642-5b8c0a7977a1",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E14--US-History---Understanding-this-Country--Presidents-setting-precedents-e2vhud8",
|
||||
"title": "E14. US History - Understanding this Country | Presidents setting precedents",
|
||||
"publishedAt": "2025-03-01T04:33:33.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "344e00d3-7a6c-4941-81d2-cb3c60f567cb",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E13--US-History---Understanding-this-Country--Inside-the-U-S--Constitution-e2v887v",
|
||||
"title": "E13. US History - Understanding this Country | Inside the U.S. Constitution",
|
||||
"publishedAt": "2025-02-23T03:35:43.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "2ffd8004-b419-4260-80de-e3e04518f70d",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E12--US-History---Understanding-this-Country--Finding-the-Balance-e2uubvc",
|
||||
"title": "E12. US History - Understanding this Country | Finding the Balance",
|
||||
"publishedAt": "2025-02-16T04:57:29.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "ba1a2bcc-8ba8-40f6-afef-5528a7dae897",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E11--US-History---Understanding-this-Country--Building-a-nation-from-the-scratch-e2uk5j4",
|
||||
"title": "E11. US History - Understanding this Country | Building a nation from the scratch",
|
||||
"publishedAt": "2025-02-09T04:42:25.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "0c25ee7a-bc69-4791-995a-07cc9456f980",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E10--US-History---Understanding-this-Country--The-Revolutions-Final-Stand-e2u63b1",
|
||||
"title": "E10. US History - Understanding this Country | The Revolution’s Final Stand",
|
||||
"publishedAt": "2025-02-01T02:00:00.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "047a6592-70d3-4ff9-9f2f-f6302c4091f0",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E9--US-History---Understanding-this-Country--From-Declaration-to-Victory-e2u0336",
|
||||
"title": "E9. US History - Understanding this Country | From Declaration to Victory",
|
||||
"publishedAt": "2025-01-26T04:01:00.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "c7484015-9116-4cd2-ae10-95e10b25cfe2",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E8--US-History---Understanding-this-Country--The-Road-to-Independence-e2tl8to",
|
||||
"title": "E8. US History - Understanding this Country | The Road to Independence",
|
||||
"publishedAt": "2025-01-18T05:08:28.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "3f3949a2-032d-41ed-bb40-29d5d39ecd63",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E7--US-History---Understanding-this-Country--The-British-Are-Coming-e2ta21g",
|
||||
"title": "E7. US History - Understanding this Country | The British Are Coming",
|
||||
"publishedAt": "2025-01-10T04:14:48.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "e415d267-4104-4f40-a6d8-364cd6c36ccb",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E6--US-History---Understanding-this-Country--The-Spark-of-Revolution-e2svhfd",
|
||||
"title": "E6. US History - Understanding this Country | The Spark of Revolution",
|
||||
"publishedAt": "2025-01-04T14:12:00.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "82811636-1652-4d69-8c80-62a3021ecc18",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E5--US-History---Understanding-this-Country--Colonial-America-The-Spark-Before-the-Revolution-e2sqg20",
|
||||
"title": "E5. US History - Understanding this Country | Colonial America: The Spark Before the Revolution",
|
||||
"publishedAt": "2024-12-28T03:46:34.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "edae75c4-3a65-4771-8445-ae4a76e9c6c9",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E4--US-History---Understanding-this-Country--Colonial-America-Roots-of-a-New-Nation-e2sdvgp",
|
||||
"title": "E4. US History - Understanding this Country | Colonial America: Roots of a New Nation",
|
||||
"publishedAt": "2024-12-20T13:30:00.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "87c0d58b-ac9f-4017-a269-c7f16ff67587",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E3--US-History---Understanding-this-Country--Empires--Exploration--and-the-Birth-of-Colonies-e2s7mt6",
|
||||
"title": "E3. US History - Understanding this Country | Empires, Exploration, and the Birth of Colonies",
|
||||
"publishedAt": "2024-12-17T04:34:51.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "d8f3684a-1fab-4ff1-a638-d48564c870a5",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E2--US-History---Understanding-this-Country--1492-and-Beyond-The-Atlantic-World-Unveiled-e2s2s85",
|
||||
"title": "E2. US History - Understanding this Country | 1492 and Beyond: The Atlantic World Unveiled",
|
||||
"publishedAt": "2024-12-09T05:11:09.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "7f5c488b-3a7b-4d62-847c-fd5a807577a9",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/E1--US-History---Understanding-this-Country-e2rojk0",
|
||||
"title": "E1. US History - Understanding this Country",
|
||||
"publishedAt": "2024-12-02T05:02:25.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "f25afab7-54b8-4a61-bc0d-143986c7d475",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/One-Person---One-Relation-e1fkfab",
|
||||
"title": "One Person - One Relation",
|
||||
"publishedAt": "2022-03-12T19:43:48.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded400/7490178/7490178-1644680237670-d15b3f1acda1b.jpg"
|
||||
},
|
||||
{
|
||||
"id": "95b07a0e-6f13-4822-9d4f-2fa74dd4cff9",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Dharmaraja---Conclusion-e1dvq6i",
|
||||
"title": "Dharmaraja.- Conclusion",
|
||||
"publishedAt": "2022-02-05T19:28:44.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644089311927-91c36cb8383e5.jpg"
|
||||
},
|
||||
{
|
||||
"id": "e72fb42d-1f10-4fe6-842c-811e260a54e7",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Dharmaraja---Episode-28-e1dvpa3",
|
||||
"title": "Dharmaraja - Episode 28",
|
||||
"publishedAt": "2022-02-05T19:02:09.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644087715926-5f708b8948277.jpg"
|
||||
},
|
||||
{
|
||||
"id": "e1c0d4dc-35e7-4cb7-b022-0bebef8c3ed1",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Dharmaraja---Episode-27-e1dvmg0",
|
||||
"title": "Dharmaraja - Episode 27",
|
||||
"publishedAt": "2022-02-05T17:52:55.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644083569325-d4aaaf3e6d72.jpg"
|
||||
},
|
||||
{
|
||||
"id": "677892be-4457-49cb-a2b1-b7a696ea7275",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Dharmaraja---Episode-26-e1duh8e",
|
||||
"title": "Dharmaraja - Episode 26",
|
||||
"publishedAt": "2022-02-04T21:12:06.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644083230812-bbcda04085e26.jpg"
|
||||
},
|
||||
{
|
||||
"id": "8a09387c-bee8-46b9-97f1-828e28dfa09a",
|
||||
"source": "podcast",
|
||||
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Dharmaraja---Episode-25-e1dsusg",
|
||||
"title": "Dharmaraja - Episode 25",
|
||||
"publishedAt": "2022-02-03T21:23:34.000Z",
|
||||
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1643923406877-eef4729d8dab5.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
site/content/featured-videos.json
Normal file
3
site/content/featured-videos.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"videoIds": []
|
||||
}
|
||||
3
site/content/instagram-posts.json
Normal file
3
site/content/instagram-posts.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"posts": []
|
||||
}
|
||||
6958
site/package-lock.json
generated
Normal file
6958
site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
site/package.json
Normal file
32
site/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "site",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"fetch-content": "tsx scripts/fetch-content.ts",
|
||||
"typecheck": "astro check",
|
||||
"format": "prettier -w .",
|
||||
"format:check": "prettier -c .",
|
||||
"test": "vitest run",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "^3.7.0",
|
||||
"astro": "^5.17.1",
|
||||
"rss-parser": "^3.13.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.6",
|
||||
"@types/node": "^25.2.2",
|
||||
"dotenv": "^17.2.4",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
16
site/prettier.config.cjs
Normal file
16
site/prettier.config.cjs
Normal file
@@ -0,0 +1,16 @@
|
||||
/** @type {import("prettier").Config} */
|
||||
module.exports = {
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
printWidth: 100,
|
||||
trailingComma: "all",
|
||||
plugins: ["prettier-plugin-astro"],
|
||||
overrides: [
|
||||
{
|
||||
files: "*.astro",
|
||||
options: {
|
||||
parser: "astro",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
BIN
site/public/favicon.ico
Normal file
BIN
site/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 655 B |
9
site/public/favicon.svg
Normal file
9
site/public/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
5
site/public/robots.txt
Normal file
5
site/public/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: /sitemap-index.xml
|
||||
|
||||
270
site/public/styles/global.css
Normal file
270
site/public/styles/global.css
Normal file
@@ -0,0 +1,270 @@
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #0f1b38;
|
||||
--fg: #f2f4ff;
|
||||
--muted: rgba(242, 244, 255, 0.72);
|
||||
--card: rgba(255, 255, 255, 0.06);
|
||||
--card2: rgba(255, 255, 255, 0.1);
|
||||
--stroke: rgba(255, 255, 255, 0.16);
|
||||
--accent: #ffcd4a;
|
||||
--accent2: #5ee4ff;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--fg);
|
||||
background:
|
||||
radial-gradient(1000px 600px at 10% 10%, rgba(94, 228, 255, 0.22), transparent 55%),
|
||||
radial-gradient(900px 600px at 90% 20%, rgba(255, 205, 74, 0.18), transparent 50%),
|
||||
radial-gradient(900px 700px at 30% 90%, rgba(140, 88, 255, 0.14), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
Noto Sans,
|
||||
Arial,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji";
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(1100px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0 72px;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(10, 14, 28, 0.7);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 20px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: clamp(34px, 4vw, 52px);
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.cta.primary {
|
||||
border-color: rgba(255, 205, 74, 0.45);
|
||||
box-shadow: 0 0 0 3px rgba(255, 205, 74, 0.1);
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.card-media img {
|
||||
width: 110px;
|
||||
height: 70px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-placeholder {
|
||||
width: 110px;
|
||||
height: 70px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.pill-youtube {
|
||||
border-color: rgba(255, 74, 74, 0.35);
|
||||
}
|
||||
|
||||
.pill-podcast {
|
||||
border-color: rgba(94, 228, 255, 0.35);
|
||||
}
|
||||
|
||||
.pill-instagram {
|
||||
border-color: rgba(255, 205, 74, 0.35);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.instagram-media {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
border-radius: 16px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.card {
|
||||
grid-template-columns: 90px 1fr;
|
||||
}
|
||||
.card-media img,
|
||||
.card-placeholder {
|
||||
width: 90px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
101
site/scripts/fetch-content.ts
Normal file
101
site/scripts/fetch-content.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { getIngestConfigFromEnv } from "../src/lib/config";
|
||||
import type { ContentCache, ContentItem } from "../src/lib/content/types";
|
||||
import { readInstagramEmbedPosts } from "../src/lib/ingest/instagram";
|
||||
import { fetchPodcastRss } from "../src/lib/ingest/podcast";
|
||||
import { fetchYoutubeViaApi, fetchYoutubeViaRss } from "../src/lib/ingest/youtube";
|
||||
|
||||
function log(msg: string) {
|
||||
// simple, cron-friendly logs
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fetch-content] ${msg}`);
|
||||
}
|
||||
|
||||
async function writeAtomic(filePath: string, content: string) {
|
||||
const tmpPath = `${filePath}.tmp`;
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(tmpPath, content, "utf8");
|
||||
await fs.rename(tmpPath, filePath);
|
||||
}
|
||||
|
||||
function dedupe(items: ContentItem[]): ContentItem[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ContentItem[] = [];
|
||||
for (const it of items) {
|
||||
const k = `${it.source}:${it.id}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(it);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cfg = getIngestConfigFromEnv(process.env);
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
const all: ContentItem[] = [];
|
||||
|
||||
// YouTube
|
||||
if (!cfg.youtubeChannelId) {
|
||||
log("YouTube: skipped (missing YOUTUBE_CHANNEL_ID)");
|
||||
} else if (cfg.youtubeApiKey) {
|
||||
try {
|
||||
const items = await fetchYoutubeViaApi(cfg.youtubeChannelId, cfg.youtubeApiKey, 25);
|
||||
log(`YouTube: API ok (${items.length} items)`);
|
||||
all.push(...items);
|
||||
} catch (e) {
|
||||
log(`YouTube: API failed (${String(e)}), falling back to RSS`);
|
||||
const items = await fetchYoutubeViaRss(cfg.youtubeChannelId, 25);
|
||||
log(`YouTube: RSS ok (${items.length} items)`);
|
||||
all.push(...items);
|
||||
}
|
||||
} else {
|
||||
const items = await fetchYoutubeViaRss(cfg.youtubeChannelId, 25);
|
||||
log(`YouTube: RSS ok (${items.length} items)`);
|
||||
all.push(...items);
|
||||
}
|
||||
|
||||
// Podcast
|
||||
if (!cfg.podcastRssUrl) {
|
||||
log("Podcast: skipped (missing PODCAST_RSS_URL)");
|
||||
} else {
|
||||
try {
|
||||
const items = await fetchPodcastRss(cfg.podcastRssUrl, 50);
|
||||
log(`Podcast: RSS ok (${items.length} items)`);
|
||||
all.push(...items);
|
||||
} catch (e) {
|
||||
log(`Podcast: RSS failed (${String(e)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Instagram (embed-first list)
|
||||
try {
|
||||
const filePath = path.isAbsolute(cfg.instagramPostUrlsFile)
|
||||
? cfg.instagramPostUrlsFile
|
||||
: path.join(process.cwd(), cfg.instagramPostUrlsFile);
|
||||
const items = await readInstagramEmbedPosts(filePath);
|
||||
log(`Instagram: embed list ok (${items.length} items)`);
|
||||
all.push(...items);
|
||||
} catch (e) {
|
||||
log(`Instagram: embed list failed (${String(e)})`);
|
||||
}
|
||||
|
||||
const cache: ContentCache = {
|
||||
generatedAt,
|
||||
items: dedupe(all),
|
||||
};
|
||||
|
||||
const outPath = path.join(process.cwd(), "content", "cache", "content.json");
|
||||
await writeAtomic(outPath, JSON.stringify(cache, null, 2));
|
||||
log(`Wrote cache: ${outPath} (${cache.items.length} total items)`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
log(`fatal: ${String(e)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
58
site/src/components/ContentCard.astro
Normal file
58
site/src/components/ContentCard.astro
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
import type { ContentItem } from "../lib/content/types";
|
||||
|
||||
type Props = {
|
||||
item: ContentItem;
|
||||
placement: string;
|
||||
};
|
||||
|
||||
const { item, placement } = Astro.props;
|
||||
const d = new Date(item.publishedAt);
|
||||
const dateLabel = Number.isFinite(d.valueOf())
|
||||
? d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
|
||||
: "";
|
||||
|
||||
const targetId = `card.${placement}.${item.source}.${item.id}`;
|
||||
let domain = "";
|
||||
try {
|
||||
domain = new URL(item.url).hostname;
|
||||
} catch {
|
||||
domain = "";
|
||||
}
|
||||
---
|
||||
|
||||
<a
|
||||
class="card"
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="outbound_click"
|
||||
data-umami-event-target_id={targetId}
|
||||
data-umami-event-placement={placement}
|
||||
data-umami-event-target_url={item.url}
|
||||
data-umami-event-domain={domain || "unknown"}
|
||||
data-umami-event-source={item.source}
|
||||
data-umami-event-ui_placement="content_card"
|
||||
>
|
||||
<div class="card-media">
|
||||
{
|
||||
item.thumbnailUrl ? (
|
||||
<img src={item.thumbnailUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div class="card-placeholder" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-meta">
|
||||
<span class={`pill pill-${item.source}`}>{item.source}</span>
|
||||
{dateLabel ? <span class="muted">{dateLabel}</span> : null}
|
||||
{
|
||||
item.metrics?.views !== undefined ? (
|
||||
<span class="muted">{item.metrics.views.toLocaleString()} views</span>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
<h3 class="card-title">{item.title}</h3>
|
||||
</div>
|
||||
</a>
|
||||
50
site/src/components/CtaLink.astro
Normal file
50
site/src/components/CtaLink.astro
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
import { withUtm } from "../lib/url";
|
||||
|
||||
type Platform = "youtube" | "instagram" | "podcast";
|
||||
|
||||
type Props = {
|
||||
platform: Platform;
|
||||
placement: string;
|
||||
url: string;
|
||||
label: string;
|
||||
id?: string;
|
||||
campaign?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { platform, placement, url, label, id, campaign, class: className } = Astro.props;
|
||||
|
||||
const trackedUrl = withUtm(url, {
|
||||
utm_source: "website",
|
||||
utm_medium: "cta",
|
||||
utm_campaign: campaign || "social-acquisition",
|
||||
utm_content: `${platform}:${placement}`,
|
||||
});
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
const fallbackId = `cta.${placement}.${platform}.${slugify(label) || "action"}`;
|
||||
const targetId = id || fallbackId;
|
||||
---
|
||||
|
||||
<a
|
||||
class={`cta ${className || ""}`}
|
||||
href={trackedUrl}
|
||||
rel="me noopener noreferrer"
|
||||
target="_blank"
|
||||
data-umami-event="cta_click"
|
||||
data-umami-event-target_id={targetId}
|
||||
data-umami-event-placement={placement}
|
||||
data-umami-event-target_url={url}
|
||||
data-umami-event-platform={platform}
|
||||
data-umami-event-target={url}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
33
site/src/components/InstagramEmbed.astro
Normal file
33
site/src/components/InstagramEmbed.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type Props = {
|
||||
url: string;
|
||||
placement?: string;
|
||||
};
|
||||
|
||||
const { url, placement } = Astro.props;
|
||||
const p = placement || "instagram_embed";
|
||||
const targetId = `ig.${p}.${url}`;
|
||||
let domain = "";
|
||||
try {
|
||||
domain = new URL(url).hostname;
|
||||
} catch {
|
||||
domain = "";
|
||||
}
|
||||
---
|
||||
|
||||
<blockquote class="instagram-media" data-instgrm-permalink={url} data-instgrm-version="14">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="outbound_click"
|
||||
data-umami-event-target_id={targetId}
|
||||
data-umami-event-placement={p}
|
||||
data-umami-event-target_url={url}
|
||||
data-umami-event-domain={domain || "unknown"}
|
||||
data-umami-event-source="instagram"
|
||||
data-umami-event-ui_placement="instagram_embed"
|
||||
>
|
||||
View on Instagram
|
||||
</a>
|
||||
</blockquote>
|
||||
11
site/src/env.d.ts
vendored
Normal file
11
site/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_SITE_URL?: string;
|
||||
readonly PUBLIC_UMAMI_SCRIPT_URL?: string;
|
||||
readonly PUBLIC_UMAMI_WEBSITE_ID?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
102
site/src/layouts/BaseLayout.astro
Normal file
102
site/src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
import { getPublicConfig } from "../lib/config";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalPath: string;
|
||||
ogImageUrl?: string;
|
||||
};
|
||||
|
||||
const { title, description, canonicalPath, ogImageUrl } = Astro.props;
|
||||
const cfg = getPublicConfig();
|
||||
|
||||
const siteUrl = (cfg.siteUrl || "http://localhost:4321").replace(/\/$/, "");
|
||||
const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath : `/${canonicalPath}`}`;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
{ogImageUrl ? <meta property="og:image" content={ogImageUrl} /> : null}
|
||||
|
||||
<meta name="twitter:card" content={ogImageUrl ? "summary_large_image" : "summary"} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{ogImageUrl ? <meta name="twitter:image" content={ogImageUrl} /> : null}
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
|
||||
<link rel="stylesheet" href="/styles/global.css" />
|
||||
|
||||
{
|
||||
cfg.umami ? (
|
||||
<script async defer data-website-id={cfg.umami.websiteId} src={cfg.umami.scriptUrl} />
|
||||
) : null
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a
|
||||
class="brand"
|
||||
href="/"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="nav.brand"
|
||||
data-umami-event-placement="nav"
|
||||
data-umami-event-target_url="/"
|
||||
>
|
||||
SanthoshJ
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a
|
||||
href="/videos"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="nav.videos"
|
||||
data-umami-event-placement="nav"
|
||||
data-umami-event-target_url="/videos"
|
||||
>
|
||||
Videos
|
||||
</a>
|
||||
<a
|
||||
href="/podcast"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="nav.podcast"
|
||||
data-umami-event-placement="nav"
|
||||
data-umami-event-target_url="/podcast"
|
||||
>
|
||||
Podcast
|
||||
</a>
|
||||
<a
|
||||
href="/about"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="nav.about"
|
||||
data-umami-event-placement="nav"
|
||||
data-umami-event-target_url="/about"
|
||||
>
|
||||
About
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p class="muted">© {new Date().getFullYear()} SanthoshJ</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
35
site/src/lib/config.ts
Normal file
35
site/src/lib/config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
type PublicConfig = {
|
||||
siteUrl?: string;
|
||||
umami?: {
|
||||
scriptUrl: string;
|
||||
websiteId: string;
|
||||
};
|
||||
};
|
||||
|
||||
type IngestConfig = {
|
||||
youtubeChannelId?: string;
|
||||
youtubeApiKey?: string;
|
||||
podcastRssUrl?: string;
|
||||
instagramPostUrlsFile: string;
|
||||
};
|
||||
|
||||
export function getPublicConfig(): PublicConfig {
|
||||
const siteUrl = import.meta.env.PUBLIC_SITE_URL;
|
||||
const scriptUrl = import.meta.env.PUBLIC_UMAMI_SCRIPT_URL;
|
||||
const websiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
|
||||
|
||||
return {
|
||||
siteUrl,
|
||||
umami: scriptUrl && websiteId ? { scriptUrl, websiteId } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Ingestion scripts run under Node (not inside Astro runtime).
|
||||
export function getIngestConfigFromEnv(env: NodeJS.ProcessEnv): IngestConfig {
|
||||
return {
|
||||
youtubeChannelId: env.YOUTUBE_CHANNEL_ID,
|
||||
youtubeApiKey: env.YOUTUBE_API_KEY,
|
||||
podcastRssUrl: env.PODCAST_RSS_URL,
|
||||
instagramPostUrlsFile: env.INSTAGRAM_POST_URLS_FILE || "content/instagram-posts.json",
|
||||
};
|
||||
}
|
||||
26
site/src/lib/content/cache.ts
Normal file
26
site/src/lib/content/cache.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ContentCache } from "./types";
|
||||
|
||||
const DEFAULT_CACHE: ContentCache = { generatedAt: new Date(0).toISOString(), items: [] };
|
||||
|
||||
function getCachePath() {
|
||||
// Read from the repo-local content cache (populated by scripts/fetch-content.ts).
|
||||
return path.join(process.cwd(), "content", "cache", "content.json");
|
||||
}
|
||||
|
||||
export async function readContentCache(): Promise<ContentCache> {
|
||||
const cachePath = getCachePath();
|
||||
try {
|
||||
const raw = await fs.readFile(cachePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as ContentCache;
|
||||
if (!parsed || !Array.isArray(parsed.items) || typeof parsed.generatedAt !== "string") {
|
||||
return DEFAULT_CACHE;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
// Cache missing is normal for first run.
|
||||
return DEFAULT_CACHE;
|
||||
}
|
||||
}
|
||||
19
site/src/lib/content/curation.ts
Normal file
19
site/src/lib/content/curation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type FeaturedVideosFile = {
|
||||
videoIds: string[];
|
||||
};
|
||||
|
||||
export async function readFeaturedVideoIds(): Promise<string[]> {
|
||||
const p = path.join(process.cwd(), "content", "featured-videos.json");
|
||||
try {
|
||||
const raw = await fs.readFile(p, "utf8");
|
||||
const parsed = JSON.parse(raw) as FeaturedVideosFile;
|
||||
return Array.isArray(parsed.videoIds)
|
||||
? parsed.videoIds.filter((x) => typeof x === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
45
site/src/lib/content/selectors.ts
Normal file
45
site/src/lib/content/selectors.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ContentCache, ContentItem, ContentSource } from "./types";
|
||||
|
||||
export function newestItems(
|
||||
cache: ContentCache,
|
||||
limit: number,
|
||||
sources?: ContentSource[],
|
||||
): ContentItem[] {
|
||||
const items = sources ? cache.items.filter((i) => sources.includes(i.source)) : cache.items;
|
||||
return [...items]
|
||||
.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt))
|
||||
.slice(0, Math.max(0, limit));
|
||||
}
|
||||
|
||||
export function youtubeVideos(cache: ContentCache): ContentItem[] {
|
||||
return cache.items.filter((i) => i.source === "youtube");
|
||||
}
|
||||
|
||||
export function podcastEpisodes(cache: ContentCache): ContentItem[] {
|
||||
return cache.items.filter((i) => i.source === "podcast");
|
||||
}
|
||||
|
||||
export function instagramPosts(cache: ContentCache): ContentItem[] {
|
||||
return cache.items.filter((i) => i.source === "instagram");
|
||||
}
|
||||
|
||||
export function highPerformingYoutubeVideos(
|
||||
cache: ContentCache,
|
||||
limit: number,
|
||||
curatedIds: string[],
|
||||
): ContentItem[] {
|
||||
const videos = youtubeVideos(cache);
|
||||
|
||||
// If we have a curated list, it wins (keeps the page stable even without metrics).
|
||||
if (curatedIds.length > 0) {
|
||||
const byId = new Map(videos.map((v) => [v.id, v]));
|
||||
return curatedIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter(Boolean)
|
||||
.slice(0, Math.max(0, limit)) as ContentItem[];
|
||||
}
|
||||
|
||||
// Otherwise rank by views where possible.
|
||||
const ranked = [...videos].sort((a, b) => (b.metrics?.views || 0) - (a.metrics?.views || 0));
|
||||
return ranked.slice(0, Math.max(0, limit));
|
||||
}
|
||||
20
site/src/lib/content/types.ts
Normal file
20
site/src/lib/content/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type ContentSource = "youtube" | "instagram" | "podcast";
|
||||
|
||||
export type ContentMetrics = {
|
||||
views?: number;
|
||||
};
|
||||
|
||||
export type ContentItem = {
|
||||
id: string;
|
||||
source: ContentSource;
|
||||
url: string;
|
||||
title: string;
|
||||
publishedAt: string; // ISO-8601
|
||||
thumbnailUrl?: string;
|
||||
metrics?: ContentMetrics;
|
||||
};
|
||||
|
||||
export type ContentCache = {
|
||||
generatedAt: string; // ISO-8601
|
||||
items: ContentItem[];
|
||||
};
|
||||
32
site/src/lib/ingest/instagram.ts
Normal file
32
site/src/lib/ingest/instagram.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import type { ContentItem } from "../content/types";
|
||||
|
||||
type InstagramPostsFile = {
|
||||
posts: Array<{
|
||||
url: string;
|
||||
title?: string;
|
||||
publishedAt?: string;
|
||||
thumbnailUrl?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function readInstagramEmbedPosts(filePath: string): Promise<ContentItem[]> {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as InstagramPostsFile;
|
||||
const now = new Date().toISOString();
|
||||
const posts = Array.isArray(parsed.posts) ? parsed.posts : [];
|
||||
|
||||
return posts
|
||||
.filter((p) => typeof p.url === "string" && p.url.length > 0)
|
||||
.map((p) => ({
|
||||
id: p.url,
|
||||
source: "instagram" as const,
|
||||
url: p.url,
|
||||
title: p.title || "Instagram post",
|
||||
// If the user doesn't provide a publish date, we still generate valid ISO-8601.
|
||||
// It won't be accurate, but it keeps the system functional until real ingestion is added.
|
||||
publishedAt: p.publishedAt ? new Date(p.publishedAt).toISOString() : now,
|
||||
thumbnailUrl: p.thumbnailUrl,
|
||||
}));
|
||||
}
|
||||
27
site/src/lib/ingest/podcast.ts
Normal file
27
site/src/lib/ingest/podcast.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import Parser from "rss-parser";
|
||||
|
||||
import type { ContentItem } from "../content/types";
|
||||
|
||||
export async function fetchPodcastRss(rssUrl: string, limit = 50): Promise<ContentItem[]> {
|
||||
const parser = new Parser();
|
||||
const feed = await parser.parseURL(rssUrl);
|
||||
return normalizePodcastFeedItems(feed.items || [], limit);
|
||||
}
|
||||
|
||||
export function normalizePodcastFeedItems(items: any[], limit: number): ContentItem[] {
|
||||
const out = (items || []).slice(0, limit).map((it) => {
|
||||
const url = it.link || "";
|
||||
const id = (it.guid || it.id || url).toString();
|
||||
const publishedAt = (it.isoDate || it.pubDate || new Date(0).toISOString()).toString();
|
||||
return {
|
||||
id,
|
||||
source: "podcast" as const,
|
||||
url,
|
||||
title: (it.title || "").toString(),
|
||||
publishedAt: new Date(publishedAt).toISOString(),
|
||||
thumbnailUrl: (it.itunes?.image || undefined) as string | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return out.filter((x) => x.url && x.title);
|
||||
}
|
||||
6
site/src/lib/ingest/types.ts
Normal file
6
site/src/lib/ingest/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { ContentItem, ContentSource } from "../content/types";
|
||||
|
||||
export type IngestResult = {
|
||||
source: ContentSource;
|
||||
items: ContentItem[];
|
||||
};
|
||||
112
site/src/lib/ingest/youtube.ts
Normal file
112
site/src/lib/ingest/youtube.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import Parser from "rss-parser";
|
||||
|
||||
import type { ContentItem } from "../content/types";
|
||||
|
||||
type YoutubeApiVideo = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
publishedAt: string;
|
||||
thumbnailUrl?: string;
|
||||
views?: number;
|
||||
};
|
||||
|
||||
export async function fetchYoutubeViaRss(channelId: string, limit = 20): Promise<ContentItem[]> {
|
||||
const feedUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${encodeURIComponent(channelId)}`;
|
||||
const parser = new Parser();
|
||||
const feed = await parser.parseURL(feedUrl);
|
||||
|
||||
return normalizeYoutubeRssFeedItems(feed.items || [], limit);
|
||||
}
|
||||
|
||||
async function youtubeApiGetJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`YouTube API request failed: ${res.status} ${res.statusText}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function normalizeYoutubeRssFeedItems(items: any[], limit: number): ContentItem[] {
|
||||
return (items || [])
|
||||
.slice(0, limit)
|
||||
.map((it) => {
|
||||
const url = it.link || "";
|
||||
const id = (it.id || url).toString();
|
||||
const publishedAt = (it.isoDate || it.pubDate || new Date(0).toISOString()).toString();
|
||||
return {
|
||||
id,
|
||||
source: "youtube" as const,
|
||||
url,
|
||||
title: (it.title || "").toString(),
|
||||
publishedAt: new Date(publishedAt).toISOString(),
|
||||
thumbnailUrl: (it.enclosure?.url || undefined) as string | undefined,
|
||||
};
|
||||
})
|
||||
.filter((x) => x.url && x.title);
|
||||
}
|
||||
|
||||
export function normalizeYoutubeApiVideos(
|
||||
items: Array<{
|
||||
id: string;
|
||||
snippet: { title: string; publishedAt: string; thumbnails?: Record<string, { url: string }> };
|
||||
statistics?: { viewCount?: string };
|
||||
}>,
|
||||
): ContentItem[] {
|
||||
const normalized: YoutubeApiVideo[] = (items || []).map((v) => ({
|
||||
id: v.id,
|
||||
url: `https://www.youtube.com/watch?v=${encodeURIComponent(v.id)}`,
|
||||
title: v.snippet.title,
|
||||
publishedAt: new Date(v.snippet.publishedAt).toISOString(),
|
||||
thumbnailUrl: v.snippet.thumbnails?.high?.url || v.snippet.thumbnails?.default?.url,
|
||||
views: v.statistics?.viewCount ? Number(v.statistics.viewCount) : undefined,
|
||||
}));
|
||||
|
||||
return normalized.map<ContentItem>((v) => ({
|
||||
id: v.id,
|
||||
source: "youtube",
|
||||
url: v.url,
|
||||
title: v.title,
|
||||
publishedAt: v.publishedAt,
|
||||
thumbnailUrl: v.thumbnailUrl,
|
||||
metrics: v.views !== undefined ? { views: v.views } : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchYoutubeViaApi(
|
||||
channelId: string,
|
||||
apiKey: string,
|
||||
limit = 20,
|
||||
): Promise<ContentItem[]> {
|
||||
// 1) Get latest video IDs from channel.
|
||||
const searchUrl =
|
||||
"https://www.googleapis.com/youtube/v3/search" +
|
||||
`?part=snippet&channelId=${encodeURIComponent(channelId)}` +
|
||||
`&maxResults=${encodeURIComponent(String(limit))}` +
|
||||
`&order=date&type=video&key=${encodeURIComponent(apiKey)}`;
|
||||
|
||||
const search = await youtubeApiGetJson<{
|
||||
items: Array<{
|
||||
id: { videoId: string };
|
||||
snippet: { title: string; publishedAt: string; thumbnails?: any };
|
||||
}>;
|
||||
}>(searchUrl);
|
||||
|
||||
const videoIds = (search.items || []).map((x) => x.id.videoId).filter(Boolean);
|
||||
if (videoIds.length === 0) return [];
|
||||
|
||||
// 2) Fetch statistics.
|
||||
const videosUrl =
|
||||
"https://www.googleapis.com/youtube/v3/videos" +
|
||||
`?part=snippet,statistics&maxResults=${encodeURIComponent(String(videoIds.length))}` +
|
||||
`&id=${encodeURIComponent(videoIds.join(","))}` +
|
||||
`&key=${encodeURIComponent(apiKey)}`;
|
||||
|
||||
const videos = await youtubeApiGetJson<{
|
||||
items: Array<{
|
||||
id: string;
|
||||
snippet: { title: string; publishedAt: string; thumbnails?: Record<string, { url: string }> };
|
||||
statistics?: { viewCount?: string };
|
||||
}>;
|
||||
}>(videosUrl);
|
||||
|
||||
return normalizeYoutubeApiVideos(videos.items || []);
|
||||
}
|
||||
5
site/src/lib/links.ts
Normal file
5
site/src/lib/links.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const LINKS = {
|
||||
youtubeChannel: "https://www.youtube.com/santhoshj",
|
||||
instagramProfile: "https://www.instagram.com/santhoshjanan/",
|
||||
podcast: "https://podcasters.spotify.com/pod/show/irregularmind", // default; override in CTA props if needed
|
||||
};
|
||||
10
site/src/lib/url.ts
Normal file
10
site/src/lib/url.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function withUtm(
|
||||
url: string,
|
||||
utm: Partial<Record<"utm_source" | "utm_medium" | "utm_campaign" | "utm_content", string>>,
|
||||
): string {
|
||||
const u = new URL(url);
|
||||
for (const [k, v] of Object.entries(utm)) {
|
||||
if (v) u.searchParams.set(k, v);
|
||||
}
|
||||
return u.toString();
|
||||
}
|
||||
34
site/src/pages/about.astro
Normal file
34
site/src/pages/about.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import CtaLink from "../components/CtaLink.astro";
|
||||
import { LINKS } from "../lib/links";
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="About | SanthoshJ"
|
||||
description="About SanthoshJ and where to follow."
|
||||
canonicalPath="/about"
|
||||
>
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>About</h2>
|
||||
<span class="muted">Tech, streaming, movies, travel</span>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<p style="margin-top: 0;">
|
||||
This is a lightweight site that aggregates my content so it can be discovered via search and
|
||||
shared cleanly.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<CtaLink platform="youtube" placement="about" url={LINKS.youtubeChannel} label="YouTube" />
|
||||
<CtaLink
|
||||
platform="instagram"
|
||||
placement="about"
|
||||
url={LINKS.instagramProfile}
|
||||
label="Instagram"
|
||||
/>
|
||||
<CtaLink platform="podcast" placement="about" url={LINKS.podcast} label="Podcast" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
238
site/src/pages/index.astro
Normal file
238
site/src/pages/index.astro
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import CtaLink from "../components/CtaLink.astro";
|
||||
import ContentCard from "../components/ContentCard.astro";
|
||||
import InstagramEmbed from "../components/InstagramEmbed.astro";
|
||||
import { readContentCache } from "../lib/content/cache";
|
||||
import {
|
||||
newestItems,
|
||||
highPerformingYoutubeVideos,
|
||||
instagramPosts,
|
||||
podcastEpisodes,
|
||||
} from "../lib/content/selectors";
|
||||
import { readFeaturedVideoIds } from "../lib/content/curation";
|
||||
import { LINKS } from "../lib/links";
|
||||
|
||||
const cache = await readContentCache();
|
||||
const featuredIds = await readFeaturedVideoIds();
|
||||
|
||||
const newest = newestItems(cache, 9);
|
||||
const highPerf = highPerformingYoutubeVideos(cache, 6, featuredIds);
|
||||
const ig = instagramPosts(cache).slice(0, 6);
|
||||
const pods = podcastEpisodes(cache)
|
||||
.slice(0, 6)
|
||||
.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt));
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="SanthoshJ | Tech, streaming, movies, travel"
|
||||
description="A fast, SEO-first home for videos, movie posts, and the Irregular Mind podcast."
|
||||
canonicalPath="/"
|
||||
>
|
||||
<section class="hero">
|
||||
<div>
|
||||
<h1>Fast content. Clear next actions.</h1>
|
||||
<p>
|
||||
I post about technology, game streaming, movies, and travel. This site collects the best of
|
||||
it and points you to the platform you prefer.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<CtaLink
|
||||
platform="youtube"
|
||||
placement="hero"
|
||||
url={LINKS.youtubeChannel}
|
||||
label="Subscribe on YouTube"
|
||||
class="primary"
|
||||
/>
|
||||
<CtaLink
|
||||
platform="instagram"
|
||||
placement="hero"
|
||||
url={LINKS.instagramProfile}
|
||||
label="Follow on Instagram"
|
||||
/>
|
||||
<CtaLink
|
||||
platform="podcast"
|
||||
placement="hero"
|
||||
url={LINKS.podcast}
|
||||
label="Listen to the podcast"
|
||||
/>
|
||||
</div>
|
||||
<p class="muted" style="margin-top: 14px;">
|
||||
Last updated: {new Date(cache.generatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<strong>Goal:</strong> 10% month-over-month growth in followers and engagement.
|
||||
<br />
|
||||
<span class="muted"
|
||||
>This site is the SEO landing surface that turns search traffic into followers.</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Newest</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href="/videos"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="section_header.newest.browse_all"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url="/videos"
|
||||
>
|
||||
Browse all →
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
newest.length > 0 ? (
|
||||
<div class="grid">
|
||||
{newest.map((item) => (
|
||||
<ContentCard item={item} placement="home.newest" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
No content cache yet. Run <code>npm run fetch-content</code> in <code>site/</code>.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>High-performing videos</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href="/videos"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="section_header.high_performing.videos"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url="/videos"
|
||||
>
|
||||
Videos →
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
highPerf.length > 0 ? (
|
||||
<div class="grid">
|
||||
{highPerf.map((item) => (
|
||||
<ContentCard item={item} placement="home.high_performing" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
No video stats found yet. Add <code>YOUTUBE_API_KEY</code> or curate{" "}
|
||||
<code>content/featured-videos.json</code>.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Instagram</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href={LINKS.instagramProfile}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="outbound_click"
|
||||
data-umami-event-target_id="section_header.instagram.profile"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url={LINKS.instagramProfile}
|
||||
data-umami-event-domain="www.instagram.com"
|
||||
data-umami-event-source="instagram"
|
||||
data-umami-event-ui_placement="section_header"
|
||||
>
|
||||
Profile →
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
ig.length > 0 ? (
|
||||
<div class="grid">
|
||||
{ig.map((item) => (
|
||||
<InstagramEmbed url={item.url} placement="home.instagram" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
Add post URLs to <code>content/instagram-posts.json</code> to show Instagram here.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
{
|
||||
ig.length > 0 ? (
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{}}
|
||||
set:html={`
|
||||
(function(){
|
||||
var s = document.createElement('script');
|
||||
s.async = true;
|
||||
s.defer = true;
|
||||
s.src = 'https://www.instagram.com/embed.js';
|
||||
s.onload = function(){
|
||||
try { window.instgrm && window.instgrm.Embeds && window.instgrm.Embeds.process(); } catch(e) {}
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
`}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Podcast: Irregular Mind</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href="/podcast"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="section_header.podcast.episodes"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url="/podcast"
|
||||
>
|
||||
Episodes →
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
pods.length > 0 ? (
|
||||
<div class="grid">
|
||||
{pods.map((item) => (
|
||||
<ContentCard item={item} placement="home.podcast" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
Set <code>PODCAST_RSS_URL</code> and run <code>npm run fetch-content</code> to populate
|
||||
episodes.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Follow</h2>
|
||||
<span class="muted">Pick your platform</span>
|
||||
</div>
|
||||
<div class="cta-row">
|
||||
<CtaLink
|
||||
platform="youtube"
|
||||
placement="footer_cta"
|
||||
url={LINKS.youtubeChannel}
|
||||
label="YouTube"
|
||||
/>
|
||||
<CtaLink
|
||||
platform="instagram"
|
||||
placement="footer_cta"
|
||||
url={LINKS.instagramProfile}
|
||||
label="Instagram"
|
||||
/>
|
||||
<CtaLink platform="podcast" placement="footer_cta" url={LINKS.podcast} label="Podcast" />
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
38
site/src/pages/podcast.astro
Normal file
38
site/src/pages/podcast.astro
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import ContentCard from "../components/ContentCard.astro";
|
||||
import { readContentCache } from "../lib/content/cache";
|
||||
import { podcastEpisodes } from "../lib/content/selectors";
|
||||
|
||||
const cache = await readContentCache();
|
||||
const episodes = podcastEpisodes(cache).sort(
|
||||
(a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt),
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Podcast | Irregular Mind"
|
||||
description="Episodes from the Irregular Mind podcast."
|
||||
canonicalPath="/podcast"
|
||||
>
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Irregular Mind</h2>
|
||||
<span class="muted">{episodes.length} episodes</span>
|
||||
</div>
|
||||
{
|
||||
episodes.length > 0 ? (
|
||||
<div class="grid">
|
||||
{episodes.map((item) => (
|
||||
<ContentCard item={item} placement="podcast.list" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
No episodes yet. Set <code>PODCAST_RSS_URL</code> and run{" "}
|
||||
<code>npm run fetch-content</code>.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
74
site/src/pages/podcast/[id].astro
Normal file
74
site/src/pages/podcast/[id].astro
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||
import { readContentCache } from "../../lib/content/cache";
|
||||
import { podcastEpisodes } from "../../lib/content/selectors";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const cache = await readContentCache();
|
||||
const eps = podcastEpisodes(cache);
|
||||
return eps.map((e) => ({ params: { id: e.id }, props: { episode: e } }));
|
||||
}
|
||||
|
||||
const { episode } = Astro.props;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "PodcastEpisode",
|
||||
name: episode.title,
|
||||
datePublished: episode.publishedAt,
|
||||
url: episode.url,
|
||||
};
|
||||
|
||||
let episodeDomain = "";
|
||||
try {
|
||||
episodeDomain = new URL(episode.url).hostname;
|
||||
} catch {
|
||||
episodeDomain = "";
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${episode.title} | Irregular Mind`}
|
||||
description={`Listen: ${episode.title}`}
|
||||
canonicalPath={`/podcast/${encodeURIComponent(episode.id)}`}
|
||||
ogImageUrl={episode.thumbnailUrl}
|
||||
>
|
||||
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>{episode.title}</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href="/podcast"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="podcast_detail.back_to_podcast"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url="/podcast"
|
||||
>
|
||||
Back to podcast →
|
||||
</a>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<p style="margin-top: 0;">
|
||||
This page exists for SEO and sharing. Listening happens on your platform.
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
class="cta primary"
|
||||
href={episode.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="outbound_click"
|
||||
data-umami-event-target_id={`podcast_detail.open.${episode.id}`}
|
||||
data-umami-event-placement="podcast_detail"
|
||||
data-umami-event-target_url={episode.url}
|
||||
data-umami-event-domain={episodeDomain || "unknown"}
|
||||
data-umami-event-source="podcast"
|
||||
data-umami-event-ui_placement="podcast_detail"
|
||||
>
|
||||
Open episode
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
38
site/src/pages/videos.astro
Normal file
38
site/src/pages/videos.astro
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import ContentCard from "../components/ContentCard.astro";
|
||||
import { readContentCache } from "../lib/content/cache";
|
||||
import { youtubeVideos } from "../lib/content/selectors";
|
||||
|
||||
const cache = await readContentCache();
|
||||
const videos = youtubeVideos(cache).sort(
|
||||
(a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt),
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Videos | SanthoshJ"
|
||||
description="Latest and featured YouTube videos."
|
||||
canonicalPath="/videos"
|
||||
>
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Videos</h2>
|
||||
<span class="muted">{videos.length} items</span>
|
||||
</div>
|
||||
{
|
||||
videos.length > 0 ? (
|
||||
<div class="grid">
|
||||
{videos.map((item) => (
|
||||
<ContentCard item={item} placement="videos.list" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty">
|
||||
No videos yet. Configure <code>YOUTUBE_CHANNEL_ID</code> and run{" "}
|
||||
<code>npm run fetch-content</code>.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
75
site/src/pages/videos/[id].astro
Normal file
75
site/src/pages/videos/[id].astro
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||
import { readContentCache } from "../../lib/content/cache";
|
||||
import { youtubeVideos } from "../../lib/content/selectors";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const cache = await readContentCache();
|
||||
const videos = youtubeVideos(cache);
|
||||
return videos.map((v) => ({ params: { id: v.id }, props: { video: v } }));
|
||||
}
|
||||
|
||||
const { video } = Astro.props;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
name: video.title,
|
||||
uploadDate: video.publishedAt,
|
||||
thumbnailUrl: video.thumbnailUrl ? [video.thumbnailUrl] : undefined,
|
||||
url: video.url,
|
||||
};
|
||||
|
||||
let videoDomain = "";
|
||||
try {
|
||||
videoDomain = new URL(video.url).hostname;
|
||||
} catch {
|
||||
videoDomain = "";
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${video.title} | SanthoshJ`}
|
||||
description={`Watch: ${video.title}`}
|
||||
canonicalPath={`/videos/${encodeURIComponent(video.id)}`}
|
||||
ogImageUrl={video.thumbnailUrl}
|
||||
>
|
||||
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>{video.title}</h2>
|
||||
<a
|
||||
class="muted"
|
||||
href="/videos"
|
||||
data-umami-event="click"
|
||||
data-umami-event-target_id="video_detail.back_to_videos"
|
||||
data-umami-event-placement="section_header"
|
||||
data-umami-event-target_url="/videos"
|
||||
>
|
||||
Back to videos →
|
||||
</a>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<p style="margin-top: 0;">
|
||||
This page exists for SEO and sharing. The canonical watch page is YouTube.
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
class="cta primary"
|
||||
href={video.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="outbound_click"
|
||||
data-umami-event-target_id={`video_detail.watch.${video.id}`}
|
||||
data-umami-event-placement="video_detail"
|
||||
data-umami-event-target_url={video.url}
|
||||
data-umami-event-domain={videoDomain || "unknown"}
|
||||
data-umami-event-source="youtube"
|
||||
data-umami-event-ui_placement="video_detail"
|
||||
>
|
||||
Watch on YouTube
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
13
site/tests/fixtures/podcast-feed.xml
vendored
Normal file
13
site/tests/fixtures/podcast-feed.xml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Irregular Mind</title>
|
||||
<item>
|
||||
<guid>ep-001</guid>
|
||||
<title>Episode One</title>
|
||||
<link>https://example.com/podcast/ep-001</link>
|
||||
<pubDate>Tue, 10 Feb 2026 10:00:00 GMT</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
|
||||
17
site/tests/fixtures/youtube-feed.xml
vendored
Normal file
17
site/tests/fixtures/youtube-feed.xml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>YouTube channel feed</title>
|
||||
<entry>
|
||||
<id>yt:video:abc123</id>
|
||||
<title>Test Video One</title>
|
||||
<published>2026-02-09T12:34:56+00:00</published>
|
||||
<link rel="alternate" href="https://www.youtube.com/watch?v=abc123"/>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>yt:video:def456</id>
|
||||
<title>Test Video Two</title>
|
||||
<published>2026-02-08T10:00:00+00:00</published>
|
||||
<link rel="alternate" href="https://www.youtube.com/watch?v=def456"/>
|
||||
</entry>
|
||||
</feed>
|
||||
|
||||
13
site/tests/fixtures/youtube-videos.json
vendored
Normal file
13
site/tests/fixtures/youtube-videos.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"snippet": {
|
||||
"title": "API Video One",
|
||||
"publishedAt": "2026-02-09T12:34:56Z",
|
||||
"thumbnails": { "high": { "url": "https://img.example.com/1.jpg" } }
|
||||
},
|
||||
"statistics": { "viewCount": "12345" }
|
||||
}
|
||||
]
|
||||
}
|
||||
45
site/tests/ingest.test.ts
Normal file
45
site/tests/ingest.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import Parser from "rss-parser";
|
||||
|
||||
import { normalizePodcastFeedItems } from "../src/lib/ingest/podcast";
|
||||
import { normalizeYoutubeApiVideos, normalizeYoutubeRssFeedItems } from "../src/lib/ingest/youtube";
|
||||
|
||||
const fixturesDir = path.join(process.cwd(), "tests", "fixtures");
|
||||
|
||||
describe("ingestion normalization", () => {
|
||||
it("normalizes YouTube RSS/Atom feed items", async () => {
|
||||
const xml = await readFile(path.join(fixturesDir, "youtube-feed.xml"), "utf8");
|
||||
const parser = new Parser();
|
||||
const feed = await parser.parseString(xml);
|
||||
const items = normalizeYoutubeRssFeedItems(feed.items || [], 10);
|
||||
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items[0].source).toBe("youtube");
|
||||
expect(items[0].title).toBeTruthy();
|
||||
expect(items[0].url).toMatch(/youtube\.com/);
|
||||
expect(items[0].publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("normalizes YouTube API videos and captures view count", async () => {
|
||||
const raw = await readFile(path.join(fixturesDir, "youtube-videos.json"), "utf8");
|
||||
const json = JSON.parse(raw) as any;
|
||||
const items = normalizeYoutubeApiVideos(json.items);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].metrics?.views).toBe(12345);
|
||||
});
|
||||
|
||||
it("normalizes podcast RSS items and ensures ISO dates", async () => {
|
||||
const xml = await readFile(path.join(fixturesDir, "podcast-feed.xml"), "utf8");
|
||||
const parser = new Parser();
|
||||
const feed = await parser.parseString(xml);
|
||||
const items = normalizePodcastFeedItems(feed.items || [], 10);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].source).toBe("podcast");
|
||||
expect(items[0].publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
});
|
||||
31
site/tests/umami-attributes.test.ts
Normal file
31
site/tests/umami-attributes.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
async function read(rel: string) {
|
||||
return await readFile(path.join(process.cwd(), rel), "utf8");
|
||||
}
|
||||
|
||||
describe("umami event attributes", () => {
|
||||
it("instruments nav links using data-umami-event", async () => {
|
||||
const src = await read("src/layouts/BaseLayout.astro");
|
||||
expect(src).toContain('data-umami-event="click"');
|
||||
expect(src).toContain('data-umami-event-target_id="nav.videos"');
|
||||
expect(src).toContain('data-umami-event-placement="nav"');
|
||||
});
|
||||
|
||||
it("instruments CTAs using data-umami-event and unique target_id", async () => {
|
||||
const src = await read("src/components/CtaLink.astro");
|
||||
expect(src).toContain('data-umami-event="cta_click"');
|
||||
expect(src).toContain("data-umami-event-target_id");
|
||||
expect(src).toContain("data-umami-event-placement");
|
||||
});
|
||||
|
||||
it("instruments content cards using outbound_click", async () => {
|
||||
const src = await read("src/components/ContentCard.astro");
|
||||
expect(src).toContain('data-umami-event="outbound_click"');
|
||||
expect(src).toContain("data-umami-event-target_id");
|
||||
expect(src).toContain("data-umami-event-domain");
|
||||
});
|
||||
});
|
||||
5
site/tsconfig.json
Normal file
5
site/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user