Compare commits

..

5 Commits

Author SHA1 Message Date
3b0b97f139 deploy without node
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 02:52:14 -05:00
03df2b3a6c better cards 2026-02-10 02:34:25 -05:00
b63c62a732 better tracking 2026-02-10 01:52:41 -05:00
c1ab51a149 blog umami fix 2026-02-10 01:34:07 -05:00
f056e67eae better cache 2026-02-10 01:20:58 -05:00
94 changed files with 2387 additions and 158 deletions

View File

@@ -7,3 +7,7 @@
**/dist
**/.DS_Store
# Local secrets
**/.env
**/.env.*
!**/.env.example

68
.github/workflows/publish-image.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: publish-image
on:
push:
branches: ["main"]
workflow_dispatch:
schedule:
# Rebuild periodically so content sources can be refreshed even without code changes.
- cron: "0 9 * * *"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: site/package-lock.json
- name: Install + Fetch Content + Build Site
working-directory: site
env:
YOUTUBE_CHANNEL_ID: ${{ secrets.YOUTUBE_CHANNEL_ID }}
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
PODCAST_RSS_URL: ${{ secrets.PODCAST_RSS_URL }}
WORDPRESS_BASE_URL: ${{ secrets.WORDPRESS_BASE_URL }}
WORDPRESS_USERNAME: ${{ secrets.WORDPRESS_USERNAME }}
WORDPRESS_APP_PASSWORD: ${{ secrets.WORDPRESS_APP_PASSWORD }}
REDIS_URL: ${{ secrets.REDIS_URL }}
run: |
npm ci
npm run fetch-content
npm run build
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest
type=sha,format=short,prefix=sha-
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
BUILD_SHA=${{ github.sha }}
BUILD_DATE=${{ github.run_started_at }}
BUILD_REF=${{ github.server_url }}/${{ github.repository }}

View File

@@ -3,15 +3,31 @@ FROM node:24-alpine AS builder
WORKDIR /app/site
COPY site/package.json site/package-lock.json ./
RUN npm ci
RUN npm ci --no-audit --no-fund
COPY site/ ./
# Content is fetched before build (typically in CI) and committed into the build context at
# `site/content/cache/content.json`. If env vars aren't configured, the fetch step gracefully
# skips sources and/or uses last-known-good cache.
RUN npm run build
FROM nginx:1.27-alpine
ARG BUILD_SHA=unknown
ARG BUILD_DATE=unknown
ARG BUILD_REF=unknown
LABEL org.opencontainers.image.title="fast-website"
LABEL org.opencontainers.image.description="Lightweight, SEO-first static site packaged as an nginx image."
LABEL org.opencontainers.image.revision=$BUILD_SHA
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.source=$BUILD_REF
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/site/dist/ /usr/share/nginx/html/
EXPOSE 80
# Operator-friendly version visibility.
RUN printf '{\n "sha": "%s",\n "builtAt": "%s",\n "ref": "%s"\n}\n' "$BUILD_SHA" "$BUILD_DATE" "$BUILD_REF" \
> /usr/share/nginx/html/build.json
EXPOSE 80

View File

@@ -86,7 +86,9 @@ Instrumentation checklist:
## Deployment (Linode + Docker)
Build and run:
The production host is intentionally minimal and only needs Docker (no Node.js on the server).
### Local Docker
```bash
docker compose build
@@ -95,24 +97,29 @@ docker compose up -d
The container serves the static output on port `8080` (map or proxy as needed).
### Refreshing Content (Manual)
### Production (Docker-Only Host)
Content is fetched at build time into `site/content/cache/content.json`.
In production, CI builds and publishes a Docker image (nginx serving the static output). The server updates by pulling that image and restarting the service.
On the Linode host:
Runbook: `deploy/runbook.md`.
### Refreshing Content (Manual, Docker-Only)
Content is fetched at build time into `site/content/cache/content.json` (typically in CI), then packaged into the image.
On the server host:
```bash
./scripts/refresh.sh
```
This:
1. Runs `npm run fetch-content` in `site/`
2. Rebuilds the Docker image
3. Restarts the container
1. Pulls the latest published image
2. Restarts the service (no build on the host)
### Refreshing Content (Scheduled)
Install a daily cron using `deploy/cron.example` as a starting point.
Rollback:
- Re-run `docker compose up -d` with a previously built image/tag, or restore the last known-good repo state and rerun `scripts/refresh.sh`.
- Re-deploy a known-good image tag/digest (see `deploy/runbook.md`).

View File

@@ -0,0 +1,6 @@
services:
web:
image: ${WEB_IMAGE:?Set WEB_IMAGE to the published image tag or digest}
ports:
- "8080:80"

84
deploy/runbook.md Normal file
View File

@@ -0,0 +1,84 @@
## Deploy Runbook (Docker-Only Host)
This runbook is for a minimal production host where **Docker is installed** and **Node.js is not**.
The deployment model is:
- CI builds and publishes a Docker image containing the built static site
- the server updates by pulling that image and restarting the service
### Prerequisites
- Docker + Docker Compose plugin available on the host
- Registry access (e.g., logged in to GHCR if the image is private)
- A `WEB_IMAGE` value pointing at the image to deploy (tag or digest)
Example:
```bash
export WEB_IMAGE=ghcr.io/<owner>/<repo>:latest
```
### First-Time Start
```bash
docker compose -f deploy/docker-compose.prod.yml up -d
```
### Refresh (Pull + Restart)
Pull first (safe; does not affect the running container):
```bash
docker compose -f deploy/docker-compose.prod.yml pull
```
Then restart the service on the newly pulled image:
```bash
docker compose -f deploy/docker-compose.prod.yml up -d --no-build
```
### Verify Deployed Version
1. Check the container's image reference (tag/digest):
```bash
docker compose -f deploy/docker-compose.prod.yml ps
docker inspect --format '{{.Image}} {{.Config.Image}}' <container-id>
```
2. Check build metadata served by the site:
```bash
curl -fsS http://localhost:8080/build.json
```
### Rollback
Re-deploy a known-good version by pinning a previous tag or digest:
```bash
export WEB_IMAGE=ghcr.io/<owner>/<repo>:<known-good-tag>
docker compose -f deploy/docker-compose.prod.yml up -d --no-build
```
Recommended: record the image digest for each release (`docker inspect <image> --format '{{.Id}}'`), and use a digest pin for true immutability.
### Failure Mode Validation (Pull Failure)
If `docker compose pull` fails, **do not run** the restart step. The running site will continue serving the existing container.
To simulate a pull failure safely:
```bash
export WEB_IMAGE=ghcr.io/<owner>/<repo>:this-tag-does-not-exist
docker compose -f deploy/docker-compose.prod.yml pull
```
The pull should fail, but the current service should still be running:
```bash
docker compose -f deploy/docker-compose.prod.yml ps
curl -fsS http://localhost:8080/ > /dev/null
```

View File

@@ -1,8 +1,19 @@
services:
web:
image: ${WEB_IMAGE:-fast-website:local}
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:80"
redis:
image: redis:7-alpine
ports:
# Use 6380 to avoid colliding with any locally installed Redis on 6379.
- "6380:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20

View File

@@ -0,0 +1,48 @@
## Context
The site is an Astro static build served via nginx. Content is gathered by build-time ingestion (`site/scripts/fetch-content.ts`) that reads/writes a repo-local cache file (`site/content/cache/content.json`).
Today, repeated ingestion runs can re-hit external sources (YouTube API/RSS, podcast RSS, WordPress `wp-json`) and re-do normalization work. We want a shared caching layer to reduce IO and network load and to make repeated runs faster and more predictable.
## Goals / Non-Goals
**Goals:**
- Add a Redis-backed cache layer usable from Node scripts (ingestion) with TTL-based invalidation.
- Use the cache layer to reduce repeated network/API calls and parsing work for:
- social content ingestion (YouTube/podcast/Instagram list)
- WordPress `wp-json` ingestion
- Provide a default “industry standard” TTL with environment override.
- Add a manual cache clear command/script.
- Provide verification (tests and/or logs) that cache hits occur and TTL expiration behaves as expected.
**Non-Goals:**
- Adding a runtime server for the site (the site remains static HTML served by nginx).
- Caching browser requests to nginx (no CDN/edge cache configuration in this change).
- Perfect cache coherence across multiple machines/environments (dev+docker is the target).
## Decisions
- **Decision: Use Redis as the shared cache backend (docker-compose service).**
- Rationale: Redis is widely adopted, lightweight, supports TTLs natively, and is easy to run in dev via Docker.
- Alternative considered: Local file-based cache only. Rejected because it doesnt provide a shared service and is harder to invalidate consistently.
- **Decision: Cache at the “source fetch” and “normalized dataset” boundaries.**
- Rationale: The biggest cost is network + parsing/normalization. Caching raw API responses (or normalized outputs) by source+params gives the best win.
- Approach:
- Cache keys like `youtube:api:<channelId>:<limit>`, `podcast:rss:<url>`, `wp:posts`, `wp:pages`, `wp:categories`.
- Store JSON values, set TTL, and log hit/miss per key.
- **Decision: Default TTL = 1 hour (3600s), configurable via env.**
- Rationale: A 1h TTL is a common baseline for content freshness vs load. It also aligns with typical ingestion schedules (hourly/daily).
- Allow overrides for local testing and production tuning.
- **Decision: Cache clear script uses Redis `FLUSHDB` in the configured Redis database.**
- Rationale: Simple manual operation and easy to verify.
- Guardrail: Use a dedicated Redis DB index (e.g., `0` by default) so the script is scoped.
## Risks / Trade-offs
- [Risk] Redis introduces a new dependency and operational moving part. -> Mitigation: Keep Redis optional; ingestion should fall back to no-cache mode if Redis is not reachable.
- [Risk] Stale content if TTL too long. -> Mitigation: Default to 1h and allow env override; provide manual clear command.
- [Risk] Cache key mistakes lead to wrong content reuse. -> Mitigation: Centralize key generation and add tests for key uniqueness and TTL behavior.

View File

@@ -0,0 +1,28 @@
## Why
Reduce IO and external fetch load by adding a shared caching layer so repeated requests for the same content do not re-hit disk/network unnecessarily.
## What Changes
- Add a caching layer (Redis or similar lightweight cache) used by the sites data/ingestion flows.
- Add a cache service to `docker-compose.yml`.
- Define an industry-standard cache invalidation interval (TTL) with a sensible default and allow it to be configured via environment variables.
- Add a script/command to manually clear the cache on demand.
- Add verification that the cache is working (cache hits/misses and TTL behavior).
## Capabilities
### New Capabilities
- `cache-layer`: Provide a shared caching service (Redis or equivalent) with TTL-based invalidation and a manual clear operation for the websites data flows.
### Modified Capabilities
- `social-content-aggregation`: Use the cache layer to avoid re-fetching or re-processing external content sources on repeated runs/requests.
- `wordpress-content-source`: Use the cache layer to reduce repeated `wp-json` fetches and parsing work.
## Impact
- Deployment/local dev: add Redis (or equivalent) to `docker-compose.yml` and wire environment/config for connection + TTL.
- Scripts/services: update ingestion/build-time fetch to read/write via cache and log hit/miss for verification.
- Tooling: add a cache-clear script/command (and document usage).
- Testing: add tests or a lightweight verification step proving cached reads are used and expire as expected.

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Redis-backed cache service
The system MUST provide a Redis-backed cache service for use by ingestion and content processing flows.
The cache service MUST be runnable in local development via Docker Compose.
#### Scenario: Cache service available in Docker
- **WHEN** the Docker Compose stack is started
- **THEN** a Redis service is available to other services/scripts on the internal network
### Requirement: TTL-based invalidation
Cached entries MUST support TTL-based invalidation.
The system MUST define a default TTL and MUST allow overriding the TTL via environment/config.
#### Scenario: Default TTL applies
- **WHEN** a cached entry is written without an explicit TTL override
- **THEN** it expires after the configured default TTL
#### Scenario: TTL override applies
- **WHEN** a TTL override is configured via environment/config
- **THEN** new cached entries use that TTL for expiration
### Requirement: Cache key namespace
Cache keys MUST be namespaced by source and parameters so that different data requests do not collide.
#### Scenario: Two different sources do not collide
- **WHEN** the system caches a YouTube fetch and a WordPress fetch
- **THEN** they use different key namespaces and do not overwrite each other
### Requirement: Manual cache clear
The system MUST provide a script/command to manually clear the cache.
#### Scenario: Manual clear executed
- **WHEN** a developer runs the cache clear command
- **THEN** the cache is cleared and subsequent ingestion runs produce cache misses

View File

@@ -0,0 +1,23 @@
## MODIFIED Requirements
### Requirement: Refresh and caching
The system MUST cache the latest successful ingestion output and MUST serve the cached data to the site renderer.
The system MUST support periodic refresh on a schedule (at minimum daily) and MUST support a manual refresh trigger.
On ingestion failure, the system MUST continue serving the most recent cached data.
The ingestion pipeline MUST use the cache layer (when configured and reachable) to reduce repeated network and parsing work for external sources (for example, YouTube API/RSS and podcast RSS).
#### Scenario: Scheduled refresh fails
- **WHEN** a scheduled refresh run fails to fetch one or more sources
- **THEN** the site continues to use the most recent successfully cached dataset
#### Scenario: Manual refresh requested
- **WHEN** a manual refresh is triggered
- **THEN** the system attempts ingestion immediately and updates the cache if ingestion succeeds
#### Scenario: Cache hit avoids refetch
- **WHEN** a refresh run is executed within the cache TTL for a given source+parameters
- **THEN** the ingestion pipeline uses cached data for that source instead of refetching over the network

View File

@@ -0,0 +1,19 @@
## MODIFIED Requirements
### Requirement: Build-time caching
WordPress posts, pages, and categories MUST be written into the repo-local content cache used by the site build.
If the WordPress fetch fails, the system MUST NOT crash the entire build pipeline; it MUST either:
- keep the last-known-good cached WordPress content (if present), or
- store an empty WordPress dataset and allow the rest of the site to build.
When the cache layer is configured and reachable, the WordPress ingestion MUST cache `wp-json` responses (or normalized outputs) using a TTL so repeated ingestion runs avoid unnecessary network requests and parsing work.
#### Scenario: WordPress fetch fails
- **WHEN** a WordPress API request fails
- **THEN** the site build can still complete and the blog surface renders a graceful empty state
#### Scenario: Cache hit avoids wp-json refetch
- **WHEN** WordPress ingestion is executed within the configured cache TTL
- **THEN** it uses cached data instead of refetching from `wp-json`

View File

@@ -0,0 +1,26 @@
## 1. Cache Service And Config
- [x] 1.1 Add Redis service to `docker-compose.yml` and wire basic health/ports for local dev
- [x] 1.2 Add cache env/config variables (Redis URL/host+port, DB index, default TTL seconds) and document in `site/.env.example`
## 2. Cache Client And Utilities
- [x] 2.1 Add a small Redis cache client wrapper (get/set JSON with TTL, namespaced keys) for Node scripts
- [x] 2.2 Add logging for cache hit/miss per key to support verification
- [x] 2.3 Ensure caching is optional: if Redis is unreachable, ingestion proceeds without caching
## 3. Integrate With Ingestion
- [x] 3.1 Cache YouTube fetches (API and/or RSS) by source+params and reuse within TTL
- [x] 3.2 Cache podcast RSS fetch by URL and reuse within TTL
- [x] 3.3 Cache WordPress `wp-json` fetches (posts/pages/categories) and reuse within TTL
## 4. Cache Invalidation
- [x] 4.1 Add a command/script to manually clear the cache (scoped to configured Redis DB)
- [x] 4.2 Document the cache clear command usage
## 5. Verification
- [x] 5.1 Add a test that exercises the cache wrapper (set/get JSON + TTL expiration behavior)
- [x] 5.2 Add a test or build verification that a second ingestion run within TTL produces cache hits

View File

@@ -0,0 +1,56 @@
## Context
The site uses Umami custom events via data attributes on clickables (e.g., navigation, CTAs, outbound links). Today, most tracked links include stable identifiers like `target_id`, `placement`, and (for links) `target_url`.
This is sufficient to measure *where* users clicked, but it is limited for content discovery because it does not capture content metadata (e.g., which specific video/post title was clicked). Umami supports adding additional event data via `data-umami-event-*` attributes, which are recorded as strings.
## Goals / Non-Goals
**Goals:**
- Add content metadata fields to Umami click tracking for content-related links:
- `title` (human-readable title)
- `type` (content type)
- Apply consistently across content surfaces (videos, podcast, blog).
- Keep existing taxonomy constraints intact:
- stable deterministic `target_id`
- `placement`
- `target_url` for links
- Avoid tracking PII.
**Non-Goals:**
- Introducing JavaScript-based `window.umami.track` calls (continue using Umami data-attribute tracking).
- Tracking clicks inside arbitrary WordPress-rendered HTML bodies (future enhancement if needed).
- Changing Umami initialization or environment configuration.
## Decisions
- **Decision: Use Option 1 (separate `title` and `type` fields).**
- Rationale: Makes reporting and filtering easier (segment by `type`, then list top `title`). Avoids parsing concatenated strings in analytics.
- Alternative: Option 2 (single `title` field formatted as `[type]-[title]`). Rejected for reduced queryability.
- **Decision: Only apply `title`/`type` to content-related links (not all links).**
- Rationale: Many links do not map cleanly to a single content item (e.g., category nav, pagination, generic navigation).
- **Decision: Normalize type values.**
- Rationale: Stable `type` values enable dashboards to be reused over time.
- Proposed set (from specs): `video`, `podcast_episode`, `blog_post`, `blog_page`.
- **Decision: Prefer shared components to propagate tracking fields.**
- Rationale: Centralize logic and reduce missed clickables.
- Approach:
- Extend existing link/card components (where applicable) to accept optional `umamiTitle` and `umamiType` props.
- For pages that render raw `<a>` tags directly, add attributes inline.
## Risks / Trade-offs
- [Risk] Title values can change over time (content edits) which may reduce longitudinal stability.
- Mitigation: Keep `target_id` deterministic and stable; use `title` for reporting convenience only.
- [Risk] Very long titles.
- Mitigation: Truncate `title` values to a reasonable length (e.g., 120-160 chars) at instrumentation time if needed.
- [Risk] Inconsistent application across surfaces.
- Mitigation: Add tests that assert content clickables include `data-umami-event-title` and `data-umami-event-type` where applicable.

View File

@@ -0,0 +1,28 @@
## Why
Umami click tracking is currently limited to `target_id`/`placement`, which makes it harder to understand *which* specific content items (by title/type) users engage with most. Adding lightweight content metadata to click events enables clearer measurement and reporting.
## What Changes
- Extend Umami click event instrumentation so content-related links include additional event data:
- `data-umami-event-title`: the content title (e.g., post/video/episode/page title)
- `data-umami-event-type`: the content type (e.g., `blog_post`, `blog_page`, `video`, `podcast_episode`)
- Apply the above consistently across all instrumented content links (cards, lists, navigation items that represent a specific piece of content).
- Ensure the metadata is additive and does not replace the existing deterministic identifiers:
- keep `data-umami-event-target_id`
- keep `data-umami-event-placement`
- keep `data-umami-event-target_url` for links
## Capabilities
### New Capabilities
- (none)
### Modified Capabilities
- `interaction-tracking-taxonomy`: add/standardize optional content metadata fields (`title`, `type`) for tracked click events, and define allowed values for `type`.
- `analytics-umami`: require Umami Track Events data-attribute instrumentation to support the above additional `data-umami-event-*` properties on content-related clickables.
## Impact
- Affected code: shared link/card components and content listing/detail pages (videos, podcast, blog posts/pages, and any other instrumented content surfaces).
- Data: Umami event payloads will include two additional string fields for content links; dashboards/reports can segment by `type` and view top-clicked items by `title`.

View File

@@ -0,0 +1,36 @@
## MODIFIED Requirements
### Requirement: Custom event tracking
When Umami is enabled, the site MUST support custom event emission for:
- `cta_click`
- `outbound_click`
- a general click interaction event for all instrumented clickable items (per the site tracking taxonomy)
Each emitted event MUST include enough properties to segment reports by platform and placement when applicable.
All tracked clickable items MUST emit events with a unique, consistent set of data elements as defined by the site tracking taxonomy, including at minimum `target_id` and `placement`.
The site MUST instrument tracked clickables using Umami’s supported Track Events data-attribute method:
- `data-umami-event="<event-name>"`
- optional event data using `data-umami-event-*`
For content-related links (clickables representing a specific piece of content), the site MUST also provide the following Umami event data attributes:
- `data-umami-event-title`
- `data-umami-event-type`
#### Scenario: Emit outbound click event
- **WHEN** a user clicks a non-CTA outbound link from the homepage
- **THEN** the system emits an `outbound_click` event with a property identifying the destination domain
#### Scenario: Emit general click event for any clickable
- **WHEN** a user clicks an instrumented navigation link
- **THEN** the system emits a click interaction event with `target_id` and `placement`
#### Scenario: Content click includes title and type
- **WHEN** a user clicks an instrumented content link (video, podcast episode, blog post/page)
- **THEN** the emitted Umami event includes `title` and `type` properties via `data-umami-event-*` attributes
#### Scenario: Uninstrumented clicks do not break the page
- **WHEN** a user clicks an element with no tracking metadata
- **THEN** the system does not throw and navigation/interaction proceeds normally

View File

@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: Minimum required properties
Every tracked click event MUST include, at minimum:
- `target_id`
- `placement`
For links, the event MUST also include:
- `target_url` (or a stable target identifier that can be mapped to a URL)
For content-related links (clickables representing a specific piece of content), the event MUST also include:
- `title` (human-readable content title)
- `type` (content type identifier)
The `type` value MUST be one of:
- `video`
- `podcast_episode`
- `blog_post`
- `blog_page`
#### Scenario: Tracking a content card click
- **WHEN** a user clicks a content card link
- **THEN** the emitted event includes `target_id`, `placement`, and `target_url`
#### Scenario: Tracking a content link includes title and type
- **WHEN** a user clicks a content-related link that represents a specific content item
- **THEN** the emitted event includes `target_id`, `placement`, `target_url`, `title`, and `type`

View File

@@ -0,0 +1,15 @@
## 1. Update Tracking Taxonomy
- [x] 1.1 Update shared Umami instrumentation patterns to support optional `title` and `type` event data for content links (without breaking existing events)
- [x] 1.2 Ensure content `type` values are normalized (`video`, `podcast_episode`, `blog_post`, `blog_page`) and do not include PII
## 2. Instrument Content Surfaces
- [x] 2.1 Add `data-umami-event-title` and `data-umami-event-type` to video clickables (listing cards and detail navigation where applicable)
- [x] 2.2 Add `data-umami-event-title` and `data-umami-event-type` to podcast clickables (listing cards and episode links)
- [x] 2.3 Add `data-umami-event-title` and `data-umami-event-type` to blog clickables that represent specific content items (post cards, pages list links)
## 3. Verify
- [x] 3.1 Add/update tests to assert content clickables include `data-umami-event-title` and `data-umami-event-type` where required
- [x] 3.2 Build the site and confirm representative pages render the new data attributes (videos listing, podcast listing, blog listing)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -0,0 +1,44 @@
## Context
The site uses Umami for analytics. Most site clickables are instrumented using Umamis data-attribute method (`data-umami-event` and optional `data-umami-event-*` properties) so events are recorded automatically on click.
The Blog section was added recently and its clickables (post cards, category nav, page links) are not consistently emitting Umami events. This creates a measurement blind spot for the `/blog` surface.
## Goals / Non-Goals
**Goals:**
- Ensure all blog clickables emit Umami events using the documented data-attribute method.
- Ensure every tracked clickable has a deterministic, unique `target_id` and includes at minimum `placement` and `target_url` per taxonomy.
- Keep event names within Umami limits (<= 50 chars) and avoid sending event data without an event name.
- Add tests to prevent regressions (blog pages/components should contain required Umami attributes).
**Non-Goals:**
- Introducing custom JavaScript tracking (`window.umami.track`) for v1; we will use Umamis data-attribute method.
- Adding new analytics providers or changing Umami initialization.
- Tracking PII or user-generated content in event properties.
## Decisions
- **Decision: Use Umami-native data attributes on every blog clickable.**
- Rationale: Aligns with Umamis “Track events” docs and the rest of the sites tracking approach; avoids adding JS listeners that can interfere with other handlers.
- **Decision: Use consistent event names by clickable type.**
- Rationale: Keeps reporting clean while still allowing segmentation via event properties.
- Proposed:
- `click` for internal navigation links (including blog category navigation)
- `outbound_click` for external links (if any in blog chrome)
- **Decision: Add a deterministic `target_id` namespace for blog elements.**
- Rationale: Blog has many repeated elements; we need unique IDs that remain stable across builds.
- Proposed conventions:
- Blog header link: `nav.blog`
- Blog secondary nav: `blog.subnav.all`, `blog.subnav.pages`, `blog.subnav.category.<slug>`
- Blog post card: `blog.card.post.<slug>` (placement `blog.index` or `blog.category.<slug>`)
- Blog post detail back link: `blog.post.back`
- Blog page list links: `blog.pages.link.<slug>`
## Risks / Trade-offs
- [Risk] Some blog content areas render raw HTML from WordPress; links inside content are not instrumented. -> Mitigation: Track the blog chrome (cards/nav/back links) first; consider JS-based delegated tracking for content-body links in a future change if needed.
- [Risk] Over-instrumentation adds noisy events. -> Mitigation: Keep event names simple, rely on `target_id` + `placement` for segmentation, and avoid tracking non-clickable elements.

View File

@@ -0,0 +1,26 @@
## Why
The Blog sections click tracking is not firing reliably in Umami, which prevents measuring what users do in `/blog` and where they go next.
## What Changes
- Update the Blog section UI so every clickable element uses Umamis data-attribute event tracking format:
- `data-umami-event="<event-name>"`
- `data-umami-event-*` attributes for event data
- Ensure every tracked clickable item has a unique, deterministic set of event data elements (especially `target_id`, `placement`, `target_url`) so clicks can be measured independently.
- Add verification/tests to ensure Blog clickables are instrumented and follow the same taxonomy as the rest of the site.
## Capabilities
### New Capabilities
- (none)
### Modified Capabilities
- `blog-section-surface`: instrument blog clickables (post cards, post/page links, category secondary nav, blog header link) using Umami `data-umami-event` attributes.
- `interaction-tracking-taxonomy`: extend/clarify tracking rules to cover blog-specific UI elements and namespaces for `target_id`.
- `analytics-umami`: ensure the implementation adheres to Umamis Track Events specification for data attributes.
## Impact
- Affected UI/components: blog pages and components under `site/src/pages/blog/` and `site/src/components/` (cards and secondary nav), plus any shared navigation link to `/blog`.
- Testing: add/update tests to assert required Umami data attributes exist and are unique per clickable element in blog surfaces.

View File

@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: Custom event tracking
When Umami is enabled, the site MUST support custom event emission for:
- `cta_click`
- `outbound_click`
- a general click interaction event for all instrumented clickable items (per the site tracking taxonomy)
Each emitted event MUST include enough properties to segment reports by platform and placement when applicable.
All tracked clickable items MUST emit events with a unique, consistent set of data elements as defined by the site tracking taxonomy, including at minimum `target_id` and `placement`.
The site MUST instrument tracked clickables using Umamis supported Track Events data-attribute method:
- `data-umami-event="<event-name>"`
- optional event data using `data-umami-event-*`
#### Scenario: Emit outbound click event
- **WHEN** a user clicks a non-CTA outbound link from the homepage
- **THEN** the system emits an `outbound_click` event with a property identifying the destination domain
#### Scenario: Emit general click event for any clickable
- **WHEN** a user clicks an instrumented navigation link
- **THEN** the system emits a click interaction event with `target_id` and `placement`
#### Scenario: Uninstrumented clicks do not break the page
- **WHEN** a user clicks an element with no tracking metadata
- **THEN** the system does not throw and navigation/interaction proceeds normally

View File

@@ -0,0 +1,47 @@
## MODIFIED Requirements
### Requirement: Blog index listing (posts)
The site MUST provide a blog index page at `/blog` that lists WordPress posts as cards containing:
- featured image (when available)
- title
- excerpt/summary
The listing MUST be ordered by publish date descending (newest first).
Each post card MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
- `data-umami-event`
- `data-umami-event-target_id`
- `data-umami-event-placement`
- `data-umami-event-target_url`
#### Scenario: Blog index lists posts
- **WHEN** the cached WordPress dataset contains posts
- **THEN** `/blog` renders a list of post cards ordered by publish date descending
#### Scenario: Blog post card click is tracked
- **WHEN** a user clicks a blog post card on `/blog`
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
### Requirement: Category-based secondary navigation
The blog section MUST render a secondary navigation under the header derived from the cached WordPress categories.
Selecting a category MUST navigate to a category listing page showing only posts in that category.
Each secondary navigation link MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
- `data-umami-event`
- `data-umami-event-target_id`
- `data-umami-event-placement`
- `data-umami-event-target_url`
#### Scenario: Category nav present
- **WHEN** the cached WordPress dataset contains categories
- **THEN** the blog section shows a secondary navigation with those categories
#### Scenario: Category listing filters posts
- **WHEN** a user navigates to a category listing page
- **THEN** only posts assigned to that category are listed
#### Scenario: Category nav click is tracked
- **WHEN** a user clicks a category link in the blog secondary navigation
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`

View File

@@ -0,0 +1,17 @@
## MODIFIED Requirements
### Requirement: Unique identifier for every clickable item
Every clickable item that is tracked MUST have a stable identifier (`target_id`) that is unique across the site (or unique within a documented namespace).
The identifier MUST be deterministic across builds for the same element and placement.
The taxonomy MUST define namespaces for repeated UI surfaces. For the blog surface, the following namespaces MUST be used:
- `blog.subnav.*` for secondary navigation links
- `blog.card.post.<slug>` for blog post cards
- `blog.pages.link.<slug>` for blog page listing links
- `blog.post.*` / `blog.page.*` for detail page chrome links (e.g., back links)
#### Scenario: Two links in different placements
- **WHEN** two links point to the same destination but appear in different placements
- **THEN** their `target_id` values are different so their clicks can be measured independently

View File

@@ -0,0 +1,15 @@
## 1. Audit Blog Clickables
- [x] 1.1 Inventory blog clickables (`site/src/pages/blog/**`, `site/src/components/Blog*`) that should emit Umami events (post cards, category subnav, pages list links, detail chrome links)
- [x] 1.2 Confirm each clickable has the required Umami attributes and a deterministic unique `target_id` per taxonomy
## 2. Implement Umami Attributes
- [x] 2.1 Instrument blog secondary navigation links with `data-umami-event` and required event data (`target_id`, `placement`, `target_url`)
- [x] 2.2 Instrument blog post cards and any inline links in listing UIs with `data-umami-event` and required event data
- [x] 2.3 Instrument blog detail page chrome links (e.g., Back) and pages listing links with required Umami attributes
## 3. Verify
- [x] 3.1 Add/update tests to assert blog components/pages contain Umami `data-umami-event` attributes (and key properties like `target_id`, `placement`, `target_url`)
- [x] 3.2 Build the site and confirm `/blog` and a blog detail page render with instrumented clickables

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -0,0 +1,62 @@
## Context
The site renders multiple card-like UI elements today:
- videos/podcast listings use `site/src/components/ContentCard.astro`
- blog listings use `site/src/components/BlogPostCard.astro`
These cards have different layouts and metadata placement. This change standardizes the card information architecture so all cards feel consistent.
The site is statically generated (Astro). Card layout consistency should be enforced primarily through shared components and shared CSS rather than copy/paste per page.
## Goals / Non-Goals
**Goals:**
- Define and implement a single, consistent card structure:
- media (image/placeholder) at top
- title
- trimmed summary/excerpt
- meta row: date (left) + views (right, if available)
- footer: source label (youtube/podcast/blog/etc.)
- Apply to all existing card surfaces:
- `/videos` listing cards
- `/podcast` listing cards
- `/blog` post cards (and category listings)
- Keep the layout resilient when fields are missing (no views, no image, no summary).
**Non-Goals:**
- Redesigning non-card list links (e.g., simple navigation links) into cards unless needed for consistency.
- Changing Umami tracking taxonomy (attributes stay intact).
- Large typographic or theme redesign beyond card structure/spacing.
## Decisions
- **Decision: Implement a shared Card component used by existing card components.**
- Rationale: Centralizes markup and ensures layout stays consistent across surfaces.
- Approach:
- Create a new component (e.g., `Card.astro`) with props for:
- `href`, `title`, `summary`, `imageUrl`, `dateLabel`, `viewsLabel`, `sourceLabel`
- optional tracking attributes pass-through (keep existing `data-umami-*` behavior)
- Update `ContentCard.astro` and `BlogPostCard.astro` to render the shared Card component.
- **Decision: Add an optional `summary` field to normalized items.**
- Rationale: Enables the standard card layout to show trimmed summaries for videos/podcast, similar to blog excerpts.
- Approach:
- Extend the normalized content schema/types with `summary?: string`.
- Populate it during ingestion where available (YouTube description snippet; podcast episode summary/description).
- **Decision: Views are optional and shown only when available.**
- Rationale: Not all sources provide views; the layout should be consistent without forcing synthetic values.
## Risks / Trade-offs
- [Risk] Ingestion sources may provide very long summaries.
- Mitigation: Standardize trimming logic in the card component (single truncation helper).
- [Risk] CSS regressions across multiple pages.
- Mitigation: Add tests that assert key card structure/classes exist; verify build outputs for `/videos`, `/podcast`, `/blog`.
- [Risk] Blog post cards and content cards have different link targets (internal vs outbound).
- Mitigation: Shared Card component should be able to render both internal links and external links (target/rel configurable).

View File

@@ -0,0 +1,29 @@
## Why
The site currently renders multiple card variants (videos/podcast cards, blog post cards, etc.) with inconsistent structure and metadata placement, which makes the UI feel uneven. A standardized card layout will create a consistent UX across the website.
## What Changes
- Standardize the UI structure for all content cards across the site:
- featured image displayed prominently on top (when available)
- title
- summary/excerpt, trimmed
- meta row with date (left) and views (right) when available (`space-between`)
- footer row showing the content source (YouTube/podcast/blog/etc.)
- Update existing card renderers/components to use the standardized structure and styling.
- Where a content source does not provide one of the fields (for example, views for blog posts), the layout MUST still render cleanly with the missing field omitted.
## Capabilities
### New Capabilities
- `card-layout-system`: Define the standard card information architecture (image/title/summary/meta/footer) and rules for optional fields so all surfaces render consistently.
### Modified Capabilities
- `social-content-aggregation`: Extend normalized content items to include an optional `summary`/`excerpt` field where available (e.g., YouTube description snippet, podcast episode summary) so non-blog cards can display a trimmed summary.
- `blog-section-surface`: Standardize blog listing cards to include the meta row (publish date and optional views) and footer source label, consistent with the global card layout system.
## Impact
- Affected code: shared card/link components (e.g., `site/src/components/ContentCard.astro`, `site/src/components/BlogPostCard.astro`) and pages that render listings (`/`, `/videos`, `/podcast`, `/blog`).
- Data model: normalized cached items may gain an optional summary field; ingestion code may need to populate it for YouTube/podcast.
- Styling: global CSS updates to ensure consistent spacing/typography and footer/meta layout.

View File

@@ -0,0 +1,33 @@
## MODIFIED Requirements
### Requirement: Blog index listing (posts)
The site MUST provide a blog index page at `/blog` that lists WordPress posts as cards containing:
- featured image (when available)
- title
- excerpt/summary
- publish date
The card MUST render a footer bar that includes:
- publish date on the left
- views on the right when available (if views are not provided by the dataset, the card MUST omit views without breaking layout)
- a content source label (e.g., `blog`)
The listing MUST be ordered by publish date descending (newest first).
Each post card MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
- `data-umami-event`
- `data-umami-event-target_id`
- `data-umami-event-placement`
- `data-umami-event-target_url`
#### Scenario: Blog index lists posts
- **WHEN** the cached WordPress dataset contains posts
- **THEN** `/blog` renders a list of post cards ordered by publish date descending
#### Scenario: Blog post card click is tracked
- **WHEN** a user clicks a blog post card on `/blog`
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
#### Scenario: Blog post card layout is standardized
- **WHEN** `/blog` renders a blog post card
- **THEN** the card shows featured image (when available), title, trimmed excerpt, and a footer bar containing date, optional views, and a source label

View File

@@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: Standard card information architecture
All content cards rendered by the site MUST use a standardized layout so cards across different surfaces look consistent.
The standard card layout MUST be:
- featured image displayed prominently at the top (when available)
- title
- summary/excerpt text, trimmed to a fixed maximum length
- footer bar showing:
- publish date on the left
- views when available (if omitted, the footer MUST still render cleanly)
- the content source label (e.g., `youtube`, `podcast`, `blog`)
If a field is not available (for example, views for some sources), the card MUST still render cleanly with that field omitted.
#### Scenario: Card renders with all fields
- **WHEN** a content item has an image, title, summary, publish date, views, and source
- **THEN** the card renders those fields in the standard card layout order
#### Scenario: Card renders without views
- **WHEN** a content item has no views data
- **THEN** the card renders the footer bar with date + source and omits views without breaking the layout
#### Scenario: Card renders without featured image
- **WHEN** a content item has no featured image
- **THEN** the card renders a placeholder media area and still renders the remaining fields

View File

@@ -0,0 +1,27 @@
## MODIFIED Requirements
### Requirement: Normalized content items
The system MUST normalize all ingested items (YouTube videos, Instagram posts, podcast episodes) into a single internal schema so the website can render them consistently.
The normalized item MUST include at minimum:
- `id` (stable within its source)
- `source` (`youtube`, `instagram`, or `podcast`)
- `url`
- `title`
- `publishedAt` (ISO-8601)
- `thumbnailUrl` (optional)
The system MUST support an optional summary field on normalized items when available from the source:
- `summary` (optional, short human-readable excerpt suitable for cards)
#### Scenario: Normalizing a YouTube video
- **WHEN** the system ingests a YouTube video item
- **THEN** it produces a normalized item containing `id`, `source: youtube`, `url`, `title`, and `publishedAt`
#### Scenario: Normalizing a podcast episode
- **WHEN** the system ingests a podcast RSS episode
- **THEN** it produces a normalized item containing `id`, `source: podcast`, `url`, `title`, and `publishedAt`
#### Scenario: Summary available
- **WHEN** an ingested item provides summary/description content
- **THEN** the normalized item includes a `summary` suitable for rendering in cards

View File

@@ -0,0 +1,20 @@
## 1. Card Component + Styles
- [x] 1.1 Create a shared card component implementing the standard card layout (media, title, summary, meta row, footer)
- [x] 1.2 Add/adjust shared CSS so the card meta row uses `space-between` and the footer consistently shows the source label
## 2. Data Model Support
- [x] 2.1 Extend normalized `ContentItem` to support an optional `summary` field and ensure it is persisted in the content cache
- [x] 2.2 Populate `summary` for YouTube and podcast items during ingestion (safe trimming / fallback when missing)
## 3. Apply Across Site
- [x] 3.1 Update `ContentCard` surfaces (`/`, `/videos`, `/podcast`) to use the shared card layout and include date/views/source in the standard positions
- [x] 3.2 Update blog post cards (`/blog`, category listings) to use the shared card layout (including publish date and `blog` source footer)
- [x] 3.3 Ensure cards render cleanly when optional fields are missing (no image, no views, no summary)
## 4. Verify
- [x] 4.1 Add/update tests to assert standardized card structure/classes across `ContentCard` and blog post cards
- [x] 4.2 Build the site and verify `/videos`, `/podcast`, and `/blog` render cards matching the standard layout

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -0,0 +1,60 @@
## Context
- Production server environment is intentionally minimal: Docker is available, but Node.js is not installed on the host.
- The site needs a repeatable way to get to the latest built content on that server.
## Goals / Non-Goals
**Goals:**
- Update the deployed site to the latest content using Docker-only operations on the server.
- Keep the server host clean (no Node.js installation required).
- Make the refresh procedure repeatable and verifiable.
**Non-Goals:**
- Building site artifacts directly on the server host outside containers.
- Introducing a new CMS/content authoring workflow.
- Solving content freshness triggers end-to-end (webhooks, scheduling) beyond what is needed to support a Docker-based refresh.
## Decisions
1. Build in CI, deploy as a Docker image
Why: keeps host clean and makes deploy deterministic.
Alternatives considered:
- Install Node.js on the host: rejected (violates clean server requirement).
- Build on the host inside a one-off container writing to a bind mount/volume: possible, but adds operational complexity and makes server resources part of the build pipeline.
2. Refresh by pulling a published image and restarting the service
Why: the server only needs Docker + registry access.
Alternatives considered:
- File-based sync (rsync/scp) of static assets: can work, but requires separate artifact distribution and is easier to drift.
- Automated image updating (e.g., watchtower): may be useful later, but start with an explicit, documented operator command.
3. Version visibility via image metadata
Why: operators need to confirm what is running.
Approach:
- Publish images with an immutable identifier (digest) and a human-friendly tag.
- Expose build metadata through standard Docker inspection and/or a small endpoint/static file in the image.
## Risks / Trade-offs
- [Risk] Content can be stale if the CI build does not run when content changes
Mitigation: add a scheduled build and/or content-change trigger in CI (future enhancement if not already present).
- [Risk] Registry auth/secrets management on the server
Mitigation: use least-privilege registry credentials and Docker-native secret handling where available.
- [Risk] Short downtime during restart
Mitigation: use `docker compose up -d` to minimize downtime; consider health checks and rolling strategies if/when multiple replicas are used.
## Migration Plan
- Add or update the Docker image build to produce a deployable image containing the built site output.
- Update server deployment configuration (compose/service) to run the published image.
- Document the operator refresh command(s): pull latest image, restart service, verify deployed version.
- Rollback strategy: re-deploy the previously known-good image tag/digest.
## Open Questions
- What is the authoritative "latest content" source (e.g., WordPress, filesystem, git) and what is the trigger to rebuild/publish a new image?
- Where should operator commands live (Makefile, `ops/` scripts, README section)?
- What is the current deployment target (single host compose, swarm, k8s) and should this change be scoped to one?

View File

@@ -0,0 +1,25 @@
## Why
The production server only provides Docker and does not have Node.js installed. We need a way to refresh the site to the latest content on that server without installing Node.js on the host.
## What Changes
- Add a Docker-first mechanism to update the deployed site to the latest content without requiring host-installed build tooling (no Node.js on the server).
- Standardize the deploy/update flow so the server updates are performed via Docker (e.g., pulling a new artifact/image and restarting, or running a containerized refresh job).
- Document and automate the update command(s) so content refresh is repeatable and low-risk.
## Capabilities
### New Capabilities
- `docker-content-refresh`: The deployed site can be updated to the latest content on a Docker-only server (no host Node.js), using a containerized workflow.
### Modified Capabilities
None.
## Impact
- Deployment/runtime: Docker compose/service definitions, update procedure, and operational docs.
- CI/CD: build/publish pipeline may need to produce and publish deployable artifacts suitable for Docker-only servers.
- Secrets/credentials: any content source credentials needed for refresh/build must be handled via Docker-friendly secret injection.
- Observability/ops: add or adjust logging/health checks around the refresh/update step to make failures visible.

View File

@@ -0,0 +1,26 @@
## ADDED Requirements
### Requirement: Host update does not require Node.js
The system MUST provide an operator workflow to update the deployed site to the latest content without installing Node.js on the server host. Any build or content-fetch steps MUST run in containers and/or CI, not via host-installed Node.js.
#### Scenario: Operator updates without host Node.js
- **WHEN** the server host has Docker available but does not have Node.js installed
- **THEN** the operator can complete the update procedure using Docker commands only
### Requirement: Image-based content refresh is supported
The system MUST support refreshing the deployed site to the latest content by pulling a newly built deployable artifact (for example, a Docker image) and restarting the running service.
#### Scenario: Successful refresh to latest image
- **WHEN** the operator runs the documented refresh command
- **THEN** the server pulls the latest published image and restarts the service using that image
#### Scenario: Refresh failure does not break running site
- **WHEN** the operator runs the documented refresh command and the pull fails
- **THEN** the site remains running on the previously deployed image
### Requirement: Refresh is repeatable and auditable
The system MUST document the refresh procedure and provide a way to verify which version is deployed (for example, image tag/digest or build metadata).
#### Scenario: Operator verifies deployed version
- **WHEN** the operator runs the documented verification command
- **THEN** the system reports the currently deployed version identifier

View File

@@ -0,0 +1,25 @@
## 1. Discovery And Current State
- [x] 1.1 Identify current deploy target and mechanism (compose/swarm/k8s, image vs files) and document constraints in `README` or `ops/` docs
- [x] 1.2 Identify the content source(s) that define "latest content" (e.g., WordPress/API) and how builds currently fetch content
- [x] 1.3 Confirm current build output (static assets) and runtime server (e.g., nginx) requirements
## 2. Build And Publish A Deployable Artifact
- [x] 2.1 Ensure the repo can produce a deterministic production build inside CI (no host dependencies)
- [x] 2.2 Create or update a Dockerfile to build the site and package the built output into a runtime image
- [x] 2.3 Add build metadata to the image (tagging convention and/or embedded version file)
- [x] 2.4 Configure CI to build and publish the image to a registry accessible by the server
## 3. Server-Side Docker-Only Refresh Workflow
- [x] 3.1 Add or update the server Docker Compose/service definition to run the published image
- [x] 3.2 Add documented operator commands to refresh to the latest image (pull + restart)
- [x] 3.3 Add a verification command/procedure to show the currently deployed version (tag/digest/build metadata)
- [x] 3.4 Define rollback procedure to re-deploy a previous known-good tag/digest
## 4. Validation
- [x] 4.1 Validate a refresh on a test/staging server: pull latest image, restart, confirm content changes are visible
- [x] 4.2 Validate failure mode: simulate pull failure and confirm the existing site remains serving
- [x] 4.3 Update docs with a minimal "runbook" for operators (refresh, verify, rollback)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -23,6 +23,14 @@ Each emitted event MUST include enough properties to segment reports by platform
All tracked clickable items MUST emit events with a unique, consistent set of data elements as defined by the site tracking taxonomy, including at minimum `target_id` and `placement`.
The site MUST instrument tracked clickables using Umami's supported Track Events data-attribute method:
- `data-umami-event="<event-name>"`
- optional event data using `data-umami-event-*`
For content-related links (clickables representing a specific piece of content), the site MUST also provide the following Umami event data attributes:
- `data-umami-event-title`
- `data-umami-event-type`
#### Scenario: Emit outbound click event
- **WHEN** a user clicks a non-CTA outbound link from the homepage
- **THEN** the system emits an `outbound_click` event with a property identifying the destination domain
@@ -31,6 +39,10 @@ All tracked clickable items MUST emit events with a unique, consistent set of da
- **WHEN** a user clicks an instrumented navigation link
- **THEN** the system emits a click interaction event with `target_id` and `placement`
#### Scenario: Content click includes title and type
- **WHEN** a user clicks an instrumented content link (video, podcast episode, blog post/page)
- **THEN** the emitted Umami event includes `title` and `type` properties via `data-umami-event-*` attributes
#### Scenario: Uninstrumented clicks do not break the page
- **WHEN** a user clicks an element with no tracking metadata
- **THEN** the system does not throw and navigation/interaction proceeds normally

View File

@@ -0,0 +1,95 @@
## Purpose
Expose a blog section on the site backed by cached WordPress content, including listing, detail pages, and category browsing.
## ADDED Requirements
### Requirement: Primary navigation entry
The site MUST add a header navigation link to the blog index at `/blog` labeled "Blog".
#### Scenario: Blog link in header
- **WHEN** a user views any page
- **THEN** the header navigation includes a "Blog" link that navigates to `/blog`
### Requirement: Blog index listing (posts)
The site MUST provide a blog index page at `/blog` that lists WordPress posts as cards containing:
- featured image (when available)
- title
- excerpt/summary
- publish date
The listing MUST be ordered by publish date descending (newest first).
The card MUST render a footer row that includes:
- publish date on the left
- views on the right when available (if views are not provided by the dataset, the card MUST omit views without breaking layout)
- a content source label (e.g., `blog`)
Each post card MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
- `data-umami-event`
- `data-umami-event-target_id`
- `data-umami-event-placement`
- `data-umami-event-target_url`
#### Scenario: Blog index lists posts
- **WHEN** the cached WordPress dataset contains posts
- **THEN** `/blog` renders a list of post cards ordered by publish date descending
#### Scenario: Blog post card click is tracked
- **WHEN** a user clicks a blog post card on `/blog`
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
#### Scenario: Blog post card layout is standardized
- **WHEN** `/blog` renders a blog post card
- **THEN** the card shows featured image (when available), title, trimmed excerpt, and a footer bar containing date, optional views, and a source label
### Requirement: Blog post detail
The site MUST provide a blog post detail page for each WordPress post that renders:
- title
- publish date
- featured image (when available)
- full post content
#### Scenario: Post detail renders
- **WHEN** a user navigates to a blog post detail page
- **THEN** the page renders the full post content from the cached WordPress dataset
### Requirement: WordPress pages support
The blog section MUST support WordPress pages by rendering page detail routes that show:
- title
- featured image (when available)
- full page content
#### Scenario: Page detail renders
- **WHEN** a user navigates to a WordPress page detail route
- **THEN** the page renders the full page content from the cached WordPress dataset
### Requirement: Category-based secondary navigation
The blog section MUST render a secondary navigation under the header derived from the cached WordPress categories.
Selecting a category MUST navigate to a category listing page showing only posts in that category.
Each secondary navigation link MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
- `data-umami-event`
- `data-umami-event-target_id`
- `data-umami-event-placement`
- `data-umami-event-target_url`
#### Scenario: Category nav present
- **WHEN** the cached WordPress dataset contains categories
- **THEN** the blog section shows a secondary navigation with those categories
#### Scenario: Category listing filters posts
- **WHEN** a user navigates to a category listing page
- **THEN** only posts assigned to that category are listed
#### Scenario: Category nav click is tracked
- **WHEN** a user clicks a category link in the blog secondary navigation
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
### Requirement: Graceful empty states
If there are no WordPress posts available, the blog index MUST render a non-broken empty state and MUST still render header/navigation.
#### Scenario: No posts available
- **WHEN** the cached WordPress dataset contains no posts
- **THEN** `/blog` renders a helpful empty state

View File

@@ -0,0 +1,42 @@
## Purpose
Provide a shared caching layer (Redis-backed) for ingestion and content processing flows, with TTL-based invalidation and manual cache clearing.
## ADDED Requirements
### Requirement: Redis-backed cache service
The system MUST provide a Redis-backed cache service for use by ingestion and content processing flows.
The cache service MUST be runnable in local development via Docker Compose.
#### Scenario: Cache service available in Docker
- **WHEN** the Docker Compose stack is started
- **THEN** a Redis service is available to other services/scripts on the internal network
### Requirement: TTL-based invalidation
Cached entries MUST support TTL-based invalidation.
The system MUST define a default TTL and MUST allow overriding the TTL via environment/config.
#### Scenario: Default TTL applies
- **WHEN** a cached entry is written without an explicit TTL override
- **THEN** it expires after the configured default TTL
#### Scenario: TTL override applies
- **WHEN** a TTL override is configured via environment/config
- **THEN** new cached entries use that TTL for expiration
### Requirement: Cache key namespace
Cache keys MUST be namespaced by source and parameters so that different data requests do not collide.
#### Scenario: Two different sources do not collide
- **WHEN** the system caches a YouTube fetch and a WordPress fetch
- **THEN** they use different key namespaces and do not overwrite each other
### Requirement: Manual cache clear
The system MUST provide a script/command to manually clear the cache.
#### Scenario: Manual clear executed
- **WHEN** a developer runs the cache clear command
- **THEN** the cache is cleared and subsequent ingestion runs produce cache misses

View File

@@ -0,0 +1,32 @@
## Purpose
Define a standardized card layout so content cards across surfaces look consistent.
## Requirements
### Requirement: Standard card information architecture
All content cards rendered by the site MUST use a standardized layout so cards across different surfaces look consistent.
The standard card layout MUST be:
- featured image displayed prominently at the top (when available)
- title
- summary/excerpt text, trimmed to a fixed maximum length
- footer row showing:
- publish date on the left
- views when available (if omitted, the footer MUST still render cleanly)
- the content source label (e.g., `youtube`, `podcast`, `blog`)
If a field is not available (for example, views for some sources), the card MUST still render cleanly with that field omitted.
#### Scenario: Card renders with all fields
- **WHEN** a content item has an image, title, summary, publish date, views, and source
- **THEN** the card renders those fields in the standard card layout order
#### Scenario: Card renders without views
- **WHEN** a content item has no views data
- **THEN** the card renders the footer bar with date + source and omits views without breaking the layout
#### Scenario: Card renders without featured image
- **WHEN** a content item has no featured image
- **THEN** the card renders a placeholder media area and still renders the remaining fields

View File

@@ -0,0 +1,31 @@
## Purpose
Enable operators to refresh the deployed site to the latest content on a Docker-only host (no Node.js installed on the server).
## Requirements
### Requirement: Host update does not require Node.js
The system MUST provide an operator workflow to update the deployed site to the latest content without installing Node.js on the server host. Any build or content-fetch steps MUST run in containers and/or CI, not via host-installed Node.js.
#### Scenario: Operator updates without host Node.js
- **WHEN** the server host has Docker available but does not have Node.js installed
- **THEN** the operator can complete the update procedure using Docker commands only
### Requirement: Image-based content refresh is supported
The system MUST support refreshing the deployed site to the latest content by pulling a newly built deployable artifact (for example, a Docker image) and restarting the running service.
#### Scenario: Successful refresh to latest image
- **WHEN** the operator runs the documented refresh command
- **THEN** the server pulls the latest published image and restarts the service using that image
#### Scenario: Refresh failure does not break running site
- **WHEN** the operator runs the documented refresh command and the pull fails
- **THEN** the site remains running on the previously deployed image
### Requirement: Refresh is repeatable and auditable
The system MUST document the refresh procedure and provide a way to verify which version is deployed (for example, image tag/digest or build metadata).
#### Scenario: Operator verifies deployed version
- **WHEN** the operator runs the documented verification command
- **THEN** the system reports the currently deployed version identifier

View File

@@ -38,7 +38,8 @@ When `metrics.views` is not available, the system MUST render the high-performin
### Requirement: Graceful empty and error states
If a module has no content to display, the homepage MUST render a non-broken empty state for that module and MUST still render the rest of the page.
The Instagram module is an exception: if there are no Instagram items to display, the homepage MUST omit the Instagram module entirely (no empty state block) and MUST still render the rest of the page.
#### Scenario: No Instagram items available
- **WHEN** the cached dataset contains no Instagram items
- **THEN** the Instagram-related module renders an empty state and the homepage still renders other modules
- **THEN** the Instagram-related module is not rendered and the homepage still renders other modules

View File

@@ -18,6 +18,12 @@ Every clickable item that is tracked MUST have a stable identifier (`target_id`)
The identifier MUST be deterministic across builds for the same element and placement.
The taxonomy MUST define namespaces for repeated UI surfaces. For the blog surface, the following namespaces MUST be used:
- `blog.subnav.*` for secondary navigation links
- `blog.card.post.<slug>` for blog post cards
- `blog.pages.link.<slug>` for blog page listing links
- `blog.post.*` / `blog.page.*` for detail page chrome links (e.g., back links)
#### Scenario: Two links in different placements
- **WHEN** two links point to the same destination but appear in different placements
- **THEN** their `target_id` values are different so their clicks can be measured independently
@@ -30,14 +36,27 @@ Every tracked click event MUST include, at minimum:
For links, the event MUST also include:
- `target_url` (or a stable target identifier that can be mapped to a URL)
For content-related links (clickables representing a specific piece of content), the event MUST also include:
- `title` (human-readable content title)
- `type` (content type identifier)
The `type` value MUST be one of:
- `video`
- `podcast_episode`
- `blog_post`
- `blog_page`
#### Scenario: Tracking a content card click
- **WHEN** a user clicks a content card link
- **THEN** the emitted event includes `target_id`, `placement`, and `target_url`
#### Scenario: Tracking a content link includes title and type
- **WHEN** a user clicks a content-related link that represents a specific content item
- **THEN** the emitted event includes `target_id`, `placement`, `target_url`, `title`, and `type`
### Requirement: No PII in event properties
The taxonomy MUST prohibit including personally identifiable information (PII) in event names or event properties.
#### Scenario: Tracking includes only categorical metadata
- **WHEN** tracking metadata is defined for a clickable item
- **THEN** it contains only categorical identifiers (ids, placements, domains) and does not include user-provided content

View File

@@ -45,9 +45,19 @@ The site MUST provide:
- `sitemap.xml` enumerating indexable pages
- `robots.txt` that allows indexing of indexable pages
The sitemap MUST include the blog surface routes:
- `/blog`
- blog post detail routes
- blog page detail routes
- blog category listing routes
#### Scenario: Sitemap is available
- **WHEN** a crawler requests `/sitemap.xml`
- **THEN** the server returns an XML sitemap listing `/`, `/videos`, `/podcast`, and `/about`
- **THEN** the server returns an XML sitemap listing `/`, `/videos`, `/podcast`, `/about`, and `/blog`
#### Scenario: Blog URLs appear in sitemap
- **WHEN** WordPress content is available in the cache at build time
- **THEN** the generated sitemap includes the blog detail URLs for those items
### Requirement: Structured data
The site MUST support structured data (JSON-LD) for Video and Podcast content when detail pages exist, and MUST ensure the JSON-LD is valid JSON.

View File

@@ -11,6 +11,9 @@ The normalized item MUST include at minimum:
- `publishedAt` (ISO-8601)
- `thumbnailUrl` (optional)
The system MUST support an optional summary field on normalized items when available from the source:
- `summary` (optional, short human-readable excerpt suitable for cards)
#### Scenario: Normalizing a YouTube video
- **WHEN** the system ingests a YouTube video item
- **THEN** it produces a normalized item containing `id`, `source: youtube`, `url`, `title`, and `publishedAt`
@@ -19,6 +22,10 @@ The normalized item MUST include at minimum:
- **WHEN** the system ingests a podcast RSS episode
- **THEN** it produces a normalized item containing `id`, `source: podcast`, `url`, `title`, and `publishedAt`
#### Scenario: Summary available
- **WHEN** an ingested item provides summary/description content
- **THEN** the normalized item includes a `summary` suitable for rendering in cards
### Requirement: YouTube ingestion with stats when available
The system MUST support ingesting YouTube videos for channel `youtube.com/santhoshj`.
@@ -57,6 +64,8 @@ The system MUST support periodic refresh on a schedule (at minimum daily) and MU
On ingestion failure, the system MUST continue serving the most recent cached data.
The ingestion pipeline MUST use the cache layer (when configured and reachable) to reduce repeated network and parsing work for external sources (for example, YouTube API/RSS and podcast RSS).
#### Scenario: Scheduled refresh fails
- **WHEN** a scheduled refresh run fails to fetch one or more sources
- **THEN** the site continues to use the most recent successfully cached dataset
@@ -65,3 +74,6 @@ On ingestion failure, the system MUST continue serving the most recent cached da
- **WHEN** a manual refresh is triggered
- **THEN** the system attempts ingestion immediately and updates the cache if ingestion succeeds
#### Scenario: Cache hit avoids refetch
- **WHEN** a refresh run is executed within the cache TTL for a given source+parameters
- **THEN** the ingestion pipeline uses cached data for that source instead of refetching over the network

View File

@@ -0,0 +1,69 @@
## Purpose
Provide a build-time content source backed by a WordPress site via the `wp-json` REST APIs.
## ADDED Requirements
### Requirement: WordPress API configuration
The system MUST allow configuring a WordPress content source using environment/config values:
- WordPress base URL
- credentials (username + password or application password) when required by the WordPress instance
The WordPress base URL MUST be used to construct requests to the WordPress `wp-json` REST APIs.
#### Scenario: Config provided
- **WHEN** WordPress configuration values are provided
- **THEN** the system can attempt to fetch WordPress content via `wp-json`
### Requirement: Fetch posts
The system MUST fetch the latest WordPress posts via `wp-json` and map them into an internal representation with:
- stable ID
- slug
- title
- excerpt/summary
- content HTML
- featured image URL when available
- publish date/time and last modified date/time
- category assignments (IDs and slugs when available)
#### Scenario: Posts fetched successfully
- **WHEN** the WordPress posts endpoint returns a non-empty list
- **THEN** the system stores the mapped post items in the content cache for rendering
### Requirement: Fetch pages
The system MUST fetch WordPress pages via `wp-json` and map them into an internal representation with:
- stable ID
- slug
- title
- excerpt/summary when available
- content HTML
- featured image URL when available
- publish date/time and last modified date/time
#### Scenario: Pages fetched successfully
- **WHEN** the WordPress pages endpoint returns a non-empty list
- **THEN** the system stores the mapped page items in the content cache for rendering
### Requirement: Fetch categories
The system MUST fetch WordPress categories via `wp-json` and store them for rendering a category-based secondary navigation under the blog section.
#### Scenario: Categories fetched successfully
- **WHEN** the WordPress categories endpoint returns a list of categories
- **THEN** the system stores categories (ID, slug, name) in the content cache for blog navigation
### Requirement: Build-time caching
WordPress posts, pages, and categories MUST be written into the repo-local content cache used by the site build.
If the WordPress fetch fails, the system MUST NOT crash the entire build pipeline; it MUST either:
- keep the last-known-good cached WordPress content (if present), or
- store an empty WordPress dataset and allow the rest of the site to build.
When the cache layer is configured and reachable, the WordPress ingestion MUST cache `wp-json` responses (or normalized outputs) using a TTL so repeated ingestion runs avoid unnecessary network requests and parsing work.
#### Scenario: WordPress fetch fails
- **WHEN** a WordPress API request fails
- **THEN** the site build can still complete and the blog surface renders a graceful empty state
#### Scenario: Cache hit avoids wp-json refetch
- **WHEN** WordPress ingestion is executed within the configured cache TTL
- **THEN** it uses cached data instead of refetching from `wp-json`

View File

@@ -3,12 +3,10 @@ set -eu
cd "$(dirname "$0")/.."
echo "[refresh] fetching content"
(cd site && npm ci && npm run fetch-content)
echo "[refresh] pulling latest image"
docker compose -f deploy/docker-compose.prod.yml pull
echo "[refresh] building + restarting container"
docker compose build web
docker compose up -d --force-recreate web
echo "[refresh] restarting service (no build)"
docker compose -f deploy/docker-compose.prod.yml up -d --no-build
echo "[refresh] done"

View File

@@ -19,3 +19,17 @@ WORDPRESS_BASE_URL=
# Optional credentials (prefer an Application Password). Leave blank if your WP endpoints are public.
WORDPRESS_USERNAME=
WORDPRESS_APP_PASSWORD=
# Cache layer (optional; used by ingestion scripts)
# If unset, caching is disabled.
#
# Using docker-compose redis:
# CACHE_REDIS_URL=redis://localhost:6380/0
CACHE_REDIS_URL=
# Alternative config if you prefer host/port/db:
CACHE_REDIS_HOST=localhost
CACHE_REDIS_PORT=6380
CACHE_REDIS_DB=0
# Default cache TTL (seconds). 3600 = 1 hour.
CACHE_DEFAULT_TTL_SECONDS=3600

View File

@@ -1,11 +1,12 @@
{
"generatedAt": "2026-02-10T06:01:51.379Z",
"generatedAt": "2026-02-10T07:10:57.264Z",
"items": [
{
"id": "gPGbtfQdaw4",
"source": "youtube",
"url": "https://www.youtube.com/watch?v=gPGbtfQdaw4",
"title": "AI Agents Are Hiring HUMANS Now? RentAHuman.ai Explained",
"summary": "RentAHuman.ai is breaking the internet - a platform where AI agents can actually hire humans for physical-world tasks. In this video, I break down everything you need to know about this controversial new marketplace. 🤖 What is RentAHuman.…",
"publishedAt": "2026-02-08T19:57:08.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/gPGbtfQdaw4/hqdefault.jpg",
"metrics": {
@@ -17,6 +18,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=aesTuu2nS-I",
"title": "I will not die. Not today!!",
"summary": "I will not die. Not today! That's what this movie tells us. Sisu (Finnish) #movie #finnish #worldwar #gold",
"publishedAt": "2026-02-05T05:53:25.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/aesTuu2nS-I/hqdefault.jpg",
"metrics": {
@@ -28,6 +30,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=9t8cBpZLHUo",
"title": "I Cant Believe This Exists: ThePrimeagens Terminal.shop is INSANE",
"summary": "I was absolutely flabbergasted when I saw terminal.shop for the first time. Its a terminal-based online shopping experience by @ThePrimeTimeagen that proves we might have reached peak developer culture. In this video, were diving into th…",
"publishedAt": "2026-02-05T04:31:18.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/9t8cBpZLHUo/hqdefault.jpg",
"metrics": {
@@ -39,6 +42,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=71S5viSJG20",
"title": "Is This Real Life? ✈️ Ultra 4K Flight Over Europes Most Iconic Cities",
"summary": "Today we are exploring the stunningly detailed landscapes of Europe in Microsoft Flight Simulator 2024. From the historic streets of London to the romantic skyline of Paris, were pushing the new graphics engine to its absolute limits in 4…",
"publishedAt": "2026-01-29T13:54:28.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/71S5viSJG20/hqdefault.jpg",
"metrics": {
@@ -50,6 +54,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=SO-tjsB4ZJs",
"title": "16 OSCAR NOMINATIONS?! 🏆 This movie is Next Level.",
"summary": "I expected a good movie. I got a legendary one. Sinners just broke the record with 16 Academy Award nominations and honestly? It deserves more. This film is playing in a completely different league. 🎬🔥 #Sinners #Oscars #AcademyAwards #Mo…",
"publishedAt": "2026-01-28T06:30:50.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/SO-tjsB4ZJs/hqdefault.jpg",
"metrics": {
@@ -61,6 +66,7 @@
"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)",
"summary": "Can a virtual plane handle a real-world monster storm? Today we are taking to the skies in Microsoft Flight Simulator 2024 to fly directly into the heart of Winter Storm Fern. With over 10,000 flights grounded in the real world, were the…",
"publishedAt": "2026-01-26T13:12:12.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/FV30wjF1WQ4/hqdefault.jpg",
"metrics": {
@@ -72,6 +78,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=_1-albWBfoc",
"title": "#Psychological #thriller #movie from #Indonesia",
"summary": "I never knew Indonesia had this good a movie industry! My bad! This one movie made me fan of Indonesian movies. Dendam Malam Kelam!!",
"publishedAt": "2026-01-25T16:55:51.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/_1-albWBfoc/hqdefault.jpg",
"metrics": {
@@ -83,6 +90,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=ts-DWD8F68Q",
"title": "Sleepless Skies: Finding Calm Above the Clouds ✈️ | MSFS 2024 (No Commentary)",
"summary": "For the night owls and the restless minds. 🌌✨ Its one of those nights where sleep wont come, so Im taking to the skies to find some stillness. Join me for a midnight tour of the worlds most iconic locations in Microsoft Flight Simulat…",
"publishedAt": "2026-01-24T18:12:01.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/ts-DWD8F68Q/hqdefault.jpg",
"metrics": {
@@ -94,6 +102,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=nlbkGnznzA8",
"title": "A #movie that will creep you out! #survival",
"summary": "Are you in a mood for a survival-thriller movie? I've a good suggestion. Deeply disturbing one though. Later learned that it was based on a real incident. #welcomehome #movie #indian #thriller",
"publishedAt": "2026-01-22T02:21:01.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/nlbkGnznzA8/hqdefault.jpg",
"metrics": {
@@ -105,6 +114,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=zR9Ey8DjG5s",
"title": "A Tour of the Worlds Most Iconic Cities 🌍✨✈️ | MSFS 2020",
"summary": "For the night owls and the restless minds. 🌌✨ Its one of those nights where sleep wont come, so Im taking to the skies to find some stillness. Join me for a midnight tour of the worlds most iconic locations in Microsoft Flight Simulat…",
"publishedAt": "2026-01-20T17:02:14.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/zR9Ey8DjG5s/hqdefault.jpg",
"metrics": {
@@ -116,6 +126,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=oerSPWeIy5k",
"title": "🎥 Dive into the quirky world of Kumiko",
"summary": "🎥 Dive into the quirky world of “Kumiko: The Treasure Hunter” a hidden gem inspired by Fargos wild tales! This offbeat adventure follows a Tokyo office worker on a surreal quest for buried treasure in Americas frozen Midwest. Perfect…",
"publishedAt": "2026-01-20T03:01:02.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/oerSPWeIy5k/hqdefault.jpg",
"metrics": {
@@ -127,6 +138,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=fzw7GUszgdQ",
"title": "RUSTY DRIVER RETURNS 🏎️ F1 25 Short Season Practice (Logitech G920)",
"summary": "Back in the cockpit! 🏁 Tonight, we are shaking off the rust in F1 25. Its been a long time since I hit the track, so I'm jumping into a short season career mode to practice and find the limit again. I'm playing on the Xbox Series S using…",
"publishedAt": "2026-01-18T17:36:36.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/fzw7GUszgdQ/hqdefault.jpg",
"metrics": {
@@ -138,6 +150,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=dlIADQOfXlQ",
"title": "Sleepless Skies: Finding Calm Above the Clouds ✈️ | MSFS 2020 (No Commentary)",
"summary": "For the night owls and the restless minds. 🌌✨ Its one of those nights where sleep wont come, so Im taking to the skies to find some stillness. Join me for a midnight tour of the worlds most iconic locations in Microsoft Flight Simulat…",
"publishedAt": "2026-01-16T18:00:30.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/dlIADQOfXlQ/hqdefault.jpg",
"metrics": {
@@ -149,6 +162,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=0-AX9KaJUSg",
"title": "💸 Your Daily Reset 🧘 Cities: Skylines | Billionaire Paradise Build | Lofi",
"summary": "Tired of the high-stakes pressure cooker? Me too. Today is about the \"Slow Game.\" Were trading the finish line for a skyline, building a tranquil billionaire's paradise where the only goal is beauty and peace. The Vibe: - Zero-stress envi…",
"publishedAt": "2026-01-16T01:07:06.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/0-AX9KaJUSg/hqdefault.jpg",
"metrics": {
@@ -160,6 +174,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=xiSka36EF5c",
"title": "Episode 41a (Recap) - US History Podcast CatchUp: From Colonization to the Early Civil Rights Mo...",
"summary": "Im back - and I owe you an apology. I went AWOL after September for personal reasons, but US History - Understanding This Country by Irregular Mind is back in the groove. In this recap episode, I quickly bring you up to speed on everythin…",
"publishedAt": "2026-01-15T16:58:14.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/xiSka36EF5c/hqdefault.jpg",
"metrics": {
@@ -171,6 +186,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=oBXH9VhnZCs",
"title": "No Mic, Just Silk: Finding My Way in Silksong | Stress-Busting Stream",
"summary": "Stepping into the unknown to find some peace. 🕯️✨ Today, Im putting down the flight controls and picking up the needle. Ive heard so much about Hollow Knight: Silksong, and as someone looking to bust some stress, Ive decided to dive in…",
"publishedAt": "2026-01-14T16:17:29.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/oBXH9VhnZCs/hqdefault.jpg",
"metrics": {
@@ -215,6 +231,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=mj5-oTVJ0AU",
"title": "America from Above: Iconic Landmarks & Relaxing Lofi Vibes | MSFS 2020",
"summary": "From the Statue of Liberty to the Golden Gate. 🏙️✨ Today, were crossing the ocean to navigate the most recognizable skylines in the world. Experience the towering skyscrapers of New York City, the historic monuments of Washington D.C., a…",
"publishedAt": "2026-01-13T16:35:38.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/mj5-oTVJ0AU/hqdefault.jpg",
"metrics": {
@@ -237,6 +254,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=zaQyRCujqk4",
"title": "No More Red Roads! 🛑 Fixing Yesterdays Traffic Mess in Cities: Skylines",
"summary": "Is your city's traffic turning red? 🛑 In this quick highlight from my latest stream, I take on one of the worst traffic bottlenecks in my city and fix it using professional road hierarchy and some clever lane management. If you've ever st…",
"publishedAt": "2026-01-12T10:26:54.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/zaQyRCujqk4/hqdefault.jpg",
"metrics": {
@@ -259,6 +277,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=m5OEtszkSyA",
"title": "Can an Introvert Build the Perfect City? | Cities: Skylines Zen Stream",
"summary": "Welcome to a quiet corner of the internet. Today, we are building, zoning, and managing traffic in Cities: Skylines. As an introverted creator, I find peace in the details of city planning. You won't find loud commentary or high-energy sho…",
"publishedAt": "2026-01-11T22:18:08.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/m5OEtszkSyA/hqdefault.jpg",
"metrics": {
@@ -270,6 +289,7 @@
"source": "youtube",
"url": "https://www.youtube.com/watch?v=XsJCIeqFWCY",
"title": "🚧 ZERO Traffic Jams in Cities: Skylines? My Impossible Build!",
"summary": "Is your city's traffic turning red? 🛑 In this quick highlight from my latest stream, I take on one of the worst traffic bottlenecks in my city and fix it using professional road hierarchy and some clever lane management. If you've ever st…",
"publishedAt": "2026-01-11T22:15:03.000Z",
"thumbnailUrl": "https://i.ytimg.com/vi/XsJCIeqFWCY/hqdefault.jpg",
"metrics": {
@@ -281,6 +301,7 @@
"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",
"summary": "Dive into the pivotal summer of 1963 in this episode of US History - Understanding This Country, hosted by Santhosh Janardhanan. Explore the Birmingham Campaign's bold Project C, where brave children faced fire hoses and police dogs in the…",
"publishedAt": "2026-01-24T03:17:34.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -289,6 +310,7 @@
"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",
"summary": "Episode 42 is where the Civil Rights Movement shifts from courtrooms to the streets. After legal wins like Brown v. Board of Education, activists and students push for real change in everyday life-at lunch counters, bus stations, and on in…",
"publishedAt": "2026-01-16T19:59:44.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -297,6 +319,7 @@
"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 CatchUp: From Colonization to the Early Civil Rights Movement",
"summary": "Im back - and I owe you an apology. I went AWOL after September for personal reasons, but US History - Understanding This Country by Irregular Mind is back in the groove. In this recap episode, I quickly bring you up to speed on everythin…",
"publishedAt": "2026-01-15T16:24:16.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -305,6 +328,7 @@
"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",
"summary": "Explore the early US civil rights movement: Brown v. Board of Education, Montgomery Bus Boycott, Little Rock Nine, Rosa Parks, Martin Luther King Jr., school integration, nonviolence, and resistance in the 1950s. Legal cases, social justic…",
"publishedAt": "2025-09-20T03:47:37.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -313,6 +337,7 @@
"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",
"summary": "Step into the 1950s: GI Bill-fueled growth, Levittown suburbs, TV in every living room, and rock n roll teens—alongside redlining, poverty, and early sparks of Civil Rights. Booming, but brittle. Listen now.",
"publishedAt": "2025-09-15T02:28:58.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -321,6 +346,7 @@
"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: Americas First Test of the Cold War",
"summary": "From the North Korean invasion to MacArthurs Inchon landing, Chinese intervention, and Trumans clash with his general — discover how the Korean War became the blueprint for Cold War conflicts and earned the name “The Forgotten War.”",
"publishedAt": "2025-09-07T04:58:14.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -329,6 +355,7 @@
"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",
"summary": "In 1945, victory brought hope and change. From the GI Bill and baby boom to the UN, Marshall Plan, Berlin Airlift, and Trumans Fair Deal — discover how America emerged as a global leader while stepping into the Cold War.",
"publishedAt": "2025-08-16T03:52:22.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -337,6 +364,7 @@
"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",
"summary": "Coincidentally, on the anniversary of the bombing of Nagasaki, we retrace Americas path to victory in WWII — from D-Days stormed beaches and the Battle of the Bulge, to the Pacific war, the A-Bomb, and the dawn of a new world order.",
"publishedAt": "2025-08-09T04:15:45.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -345,6 +373,7 @@
"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",
"summary": "Before Normandy and D-Day, there was fear, fascism, and a world unraveling. In this episode, we trace the rise of totalitarian regimes in Europe and Asia, the blitzkrieg that stunned the West, and the slow but steady shift in American sent…",
"publishedAt": "2025-08-02T03:41:33.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -353,6 +382,7 @@
"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",
"summary": "When the Roaring Twenties crashed into economic ruin, America found itself spiraling into the Great Depression. In this episode, we explore the causes, the fallout, and the ambitious response—from Hoovers failure to FDRs New Deal. Escapi…",
"publishedAt": "2025-07-26T02:32:26.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -361,6 +391,7 @@
"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",
"summary": "Explore the dazzling highs and hidden lows of 1920s America - from jazz clubs and cultural revolutions to rising nativism, fundamentalism, and an economy teetering on collapse. This episode dives deep into how the decade shaped modern Amer…",
"publishedAt": "2025-07-19T03:58:56.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -369,6 +400,7 @@
"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",
"summary": "Explore Americas entry into World War I, from trench warfare and propaganda to Wilsons Fourteen Points and the Treaty of Versailles. Discover how the Great War reshaped the U.S. and set the stage for the Roaring Twenties.",
"publishedAt": "2025-07-12T04:33:52.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -377,6 +409,7 @@
"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",
"summary": "In this episode, we follow Americas bold stride into global influence and domestic reform through the eyes of Theodore Roosevelt, William Howard Taft, and Woodrow Wilson. From the Panama Canal to trust-busting, and from Dollar Diplomacy t…",
"publishedAt": "2025-07-05T03:30:53.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -385,6 +418,7 @@
"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",
"summary": "Explore how the United States expanded its reach beyond its borders through imperial ambition, war, and diplomacy. From Hawaii to the Philippines, this episode traces Americas rise as a world power.",
"publishedAt": "2025-06-28T03:30:57.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -393,6 +427,7 @@
"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",
"summary": "Explore the transformative Progressive Era in U.S. history — from trust-busting and muckraking journalism to child labor laws, womens suffrage, and civil rights movements. This episode dives into how reformers, activists, and everyday cit…",
"publishedAt": "2025-06-19T03:42:14.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -401,6 +436,7 @@
"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",
"summary": "In this episode, we explore the explosive rise of American cities during the late 1800s and early 1900s. From Ellis Island to ethnic neighborhoods, from nativist backlash to reform movements, discover how waves of new immigrants shaped the…",
"publishedAt": "2025-06-14T03:58:41.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -409,6 +445,7 @@
"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",
"summary": "In this episode, we explore the Second Industrial Revolution — a time of booming invention, corporate empires, and factory-floor struggles. From Edisons lightbulb to Fords assembly line, and from the rise of the corporation to the birth…",
"publishedAt": "2025-06-07T03:51:30.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -417,6 +454,7 @@
"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",
"summary": "The American frontier was not an empty land — it was home. In this powerful episode, we uncover the story of the Lakota and other Plains tribes who resisted removal, reservations, and forced assimilation. From Custers Last Stand to the bo…",
"publishedAt": "2025-05-31T04:14:46.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -425,6 +463,7 @@
"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",
"summary": "From steam engines to cattle drives, and from homesteads to populist rallies — this episode explores how the American West was won, worked, and mythologized. Discover how the Transcontinental Railroad changed everything, how farmers organi…",
"publishedAt": "2025-05-24T03:30:17.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -433,6 +472,7 @@
"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",
"summary": "After the Civil War, the United States faced its most difficult question yet: how do you rebuild a country that just tried to destroy itself? In this episode, we explore the highs and heartbreaks of Reconstruction — from the promise of fre…",
"publishedAt": "2025-05-17T04:35:58.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -441,6 +481,7 @@
"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",
"summary": "Dive deep into the American Civil War — a conflict that shattered the nation, ended slavery, and changed the course of U.S. history. This episode traces the path from secession to surrender, explores key battles like Gettysburg and Antieta…",
"publishedAt": "2025-05-10T04:07:23.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -449,6 +490,7 @@
"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",
"summary": "In this gripping episode of US History Understanding This Country, we trace the volatile road to the American Civil War—from the Wilmot Proviso to Lincolns First Inaugural Address. Learn how heated debates over slavery, landmark legisla…",
"publishedAt": "2025-05-03T03:30:33.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -457,6 +499,7 @@
"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",
"summary": "In this episode of US History Understanding This Country, explore the powerful movements that reshaped America in the mid-1800s. From immigration waves and city life challenges to the Second Great Awakening, the Temperance Movement, pris…",
"publishedAt": "2025-04-26T03:38:18.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -465,6 +508,7 @@
"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",
"summary": "In this episode of US History Understanding This Country, we explore how Texas went from Mexican territory to independent republic—and how that sparked the Mexican-American War. Learn about the Alamo, the Battle of San Jacinto, the Treat…",
"publishedAt": "2025-04-19T03:26:55.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -473,6 +517,7 @@
"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",
"summary": "In this immersive episode of US History Understanding This Country, we journey along the iconic Oregon Trail — the 2,000-mile route that carried Americas pioneer spirit westward. From river crossings and food shortages to the resilience…",
"publishedAt": "2025-04-12T04:39:58.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -481,6 +526,7 @@
"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",
"summary": "In this gripping episode of US History Understanding This Country, we journey deep into the legendary Oregon Trail — the 2,000-mile path that turned ordinary families into pioneers and a young nation into a continental power. Discover wh…",
"publishedAt": "2025-04-04T23:00:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -489,6 +535,7 @@
"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",
"summary": "In this powerful episode of US History Understanding This Country, host Santhosh Janardhanan unpacks the pivotal elections of 1824, 1828, and 1832—revealing how political rivalries, populist movements, and bitter controversies shaped the…",
"publishedAt": "2025-03-29T04:10:39.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -497,6 +544,7 @@
"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",
"summary": "Was the “Era of Good Feelings” truly a time of peace and progress—or a calm before the storm? In Episode 17 of US History Understanding This Country, we explore how the post-War of 1812 boom brought nationalism, innovation, and expansion…",
"publishedAt": "2025-03-22T01:00:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -505,6 +553,7 @@
"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",
"summary": "The War of 1812 shaped Americas destiny—Tecumsehs confederation, the Battle of Tippecanoe, the burning of Washington D.C., the Star-Spangled Banner, and Andrew Jacksons victory at New Orleans. Discover how this war redefined U.S. indepe…",
"publishedAt": "2025-03-15T01:00:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -513,6 +562,7 @@
"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",
"summary": "Thomas Jeffersons presidency reshaped America—doubling its size with the Louisiana Purchase, battling the Supreme Court, and enforcing the disastrous Embargo Act. Explore his legacy in this episode!",
"publishedAt": "2025-03-08T05:43:34.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -521,6 +571,7 @@
"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",
"summary": "Dive into the transformative era of the first presidents as they laid the groundwork for the United States by setting precedents in early America. This episode traces George Washingtons historic election, the formation of his cabinet, and…",
"publishedAt": "2025-03-01T04:33:33.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -529,6 +580,7 @@
"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",
"summary": "The U.S. Constitution was written—but could it survive the fight for approval? In this episode, we dive into the ratification debates, the clash between Federalists and Anti-Federalists, and the political deals that saved the Constitution.…",
"publishedAt": "2025-02-23T03:35:43.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -537,6 +589,7 @@
"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",
"summary": "The U.S. Constitution wasnt built overnight—it was shaped by intense debates and hard-fought compromises. In this episode, we dive into The Great Compromise, The Three-Fifths Compromise, and how these decisions created the foundation for…",
"publishedAt": "2025-02-16T04:57:29.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -545,6 +598,7 @@
"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",
"summary": "How did the U.S. go from revolution to a functioning government? Of course, it was not a cake walk. In this episode, we break down the period of Article of Confederation - the first government in the U.S, creation of the Constitution, the…",
"publishedAt": "2025-02-09T04:42:25.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -553,6 +607,7 @@
"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 Revolutions Final Stand",
"summary": "The American Revolution wasnt just won on battlefields—it was won through resilience, strategy, and unexpected allies. In this episode, we cover the final battles, the global impact of the war, and the unsung heroes who made independence…",
"publishedAt": "2025-02-01T02:00:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -561,6 +616,7 @@
"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",
"summary": "Explore the gritty journey from the Declaration of Independence to the early milestones of the American Revolution. In this episode, we cover the Battle of Long Island, Washingtons daring Delaware crossing, the turning point at Saratoga,…",
"publishedAt": "2025-01-26T04:01:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -569,6 +625,7 @@
"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",
"summary": "Join us in Episode 8 of Irregular Mind as we explore the pivotal moments that shaped America's fight for freedom. From the Second Continental Congress to the bold stand at Bunker Hill, the Olive Branch Petition, and Washingtons victory in…",
"publishedAt": "2025-01-18T05:08:28.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -577,6 +634,7 @@
"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",
"summary": "Dive into the electrifying moments that sparked the American Revolution in this episode of Irregular Mind. From the bold actions of the Sons of Liberty and Patrick Henrys fiery “Give me liberty or give me death” speech to the first shots…",
"publishedAt": "2025-01-10T04:14:48.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -585,6 +643,7 @@
"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",
"summary": "In this episode of Irregular Mind, we trace the growing tensions that lit the fuse of the American Revolution. Explore the cries of “No Taxation Without Representation”, the defiance of the Stamp Act, and the fiery rhetoric of Patrick Henr…",
"publishedAt": "2025-01-04T14:12:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -593,6 +652,7 @@
"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",
"summary": "Dive into the pivotal moments that set the stage for the American Revolution in this episode of Irregular Mind. Explore the impact of the English Bill of Rights, the Great Awakening, and the Intolerable Acts as tensions between Britain and…",
"publishedAt": "2024-12-28T03:46:34.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -601,6 +661,7 @@
"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",
"summary": "Explore the birth of Colonial America in this episode of Irregular Mind. Discover the rise of Virginia, the establishment of the Thirteen Colonies, and how regional differences shaped the early American identity. From Jamestowns struggles…",
"publishedAt": "2024-12-20T13:30:00.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -609,6 +670,7 @@
"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",
"summary": "Discover the dramatic shifts in power as the Spanish Armada falls, the rise of tobacco in Virginia, and the relentless search for the Northwest Passage. Explore the early European settlements, trade networks, and cultural exchanges that sh…",
"publishedAt": "2024-12-17T04:34:51.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -617,6 +679,7 @@
"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",
"summary": "Uncover the pivotal moments that reshaped history in Episode Two of Irregular Mind's U.S. history series, \"Understanding This Country.\" From Columbus' daring voyage and the race for riches in 15th-century Europe to the clash of cultures, t…",
"publishedAt": "2024-12-09T05:11:09.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -625,6 +688,7 @@
"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",
"summary": "I came to the United States chasing opportunities, but I quickly realized that this land is more than just skyscrapers and Hollywood. The history of this place is layered, complicated, and, honestly, sometimes stranger than fiction. And th…",
"publishedAt": "2024-12-02T05:02:25.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded/7490178/7490178-1644680234566-cf5628ab210f1.jpg"
},
@@ -633,6 +697,7 @@
"source": "podcast",
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/One-Person---One-Relation-e1fkfab",
"title": "One Person - One Relation",
"summary": "A common issue faced by most of us - relationships. Just a wakeup call on it.",
"publishedAt": "2022-03-12T19:43:48.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded400/7490178/7490178-1644680237670-d15b3f1acda1b.jpg"
},
@@ -641,6 +706,7 @@
"source": "podcast",
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Dharmaraja---Conclusion-e1dvq6i",
"title": "Dharmaraja.- Conclusion",
"summary": "Conclusion and Aftermath of Yogi's plot.",
"publishedAt": "2022-02-05T19:28:44.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644089311927-91c36cb8383e5.jpg"
},
@@ -649,6 +715,7 @@
"source": "podcast",
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Dharmaraja---Episode-28-e1dvpa3",
"title": "Dharmaraja - Episode 28",
"summary": "The final showdown. Major players face off. And Yogi does the undoable.",
"publishedAt": "2022-02-05T19:02:09.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644087715926-5f708b8948277.jpg"
},
@@ -657,6 +724,7 @@
"source": "podcast",
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Dharmaraja---Episode-27-e1dvmg0",
"title": "Dharmaraja - Episode 27",
"summary": "Yogi is shocked with a surprise news. Relationship between brothers crack. Can Yogi see what kind of mess he is into?",
"publishedAt": "2022-02-05T17:52:55.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644083569325-d4aaaf3e6d72.jpg"
},
@@ -665,6 +733,7 @@
"source": "podcast",
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Dharmaraja---Episode-26-e1duh8e",
"title": "Dharmaraja - Episode 26",
"summary": "Helplessness grasps Chandrakkaran. Thripurasundarikunjamma sees visions...",
"publishedAt": "2022-02-04T21:12:06.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1644083230812-bbcda04085e26.jpg"
},
@@ -673,6 +742,7 @@
"source": "podcast",
"url": "https://podcasters.spotify.com/pod/show/the-irregular-mind/episodes/Dharmaraja---Episode-25-e1dsusg",
"title": "Dharmaraja - Episode 25",
"summary": "Conflict comes to day light. Kesavan Kunju escapes. Padathalavan is all out beast mode after knowing what happened to Kuppassar.",
"publishedAt": "2022-02-03T21:23:34.000Z",
"thumbnailUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_episode/7490178/7490178-1643923406877-eef4729d8dab5.jpg"
}

101
site/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@astrojs/sitemap": "^3.7.0",
"astro": "^5.17.1",
"redis": "^4.7.1",
"rss-parser": "^3.13.0",
"zod": "^3.25.76"
},
@@ -1241,6 +1242,65 @@
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
"license": "MIT"
},
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@redis/graph": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/json": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/search": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/time-series": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
@@ -2515,6 +2575,15 @@
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3090,6 +3159,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -4687,6 +4765,23 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
"license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": {
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.1",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
}
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@@ -6745,6 +6840,12 @@
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",

View File

@@ -7,6 +7,7 @@
"build": "astro build",
"preview": "astro preview",
"fetch-content": "tsx scripts/fetch-content.ts",
"cache:clear": "tsx scripts/cache-clear.ts",
"verify:blog": "npm run build && tsx scripts/verify-blog-build.ts",
"typecheck": "astro check",
"format": "prettier -w .",
@@ -17,6 +18,7 @@
"dependencies": {
"@astrojs/sitemap": "^3.7.0",
"astro": "^5.17.1",
"redis": "^4.7.1",
"rss-parser": "^3.13.0",
"zod": "^3.25.76"
},

View File

@@ -114,46 +114,7 @@ a {
gap: 14px;
}
.blog-card {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
transition:
transform 120ms ease,
background 120ms ease;
}
.blog-card:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.06);
}
.blog-card img {
width: 100%;
height: 180px;
object-fit: cover;
display: block;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.blog-card-body {
padding: 12px 12px 14px;
}
.blog-card-title {
margin: 0 0 8px;
font-size: 15px;
line-height: 1.25;
letter-spacing: -0.01em;
}
.blog-card-excerpt {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
/* blog cards are now rendered via the shared `.card` component styles */
.prose {
line-height: 1.75;
@@ -256,53 +217,88 @@ a {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
align-items: stretch;
}
.card {
display: grid;
grid-template-columns: 110px 1fr;
gap: 12px;
padding: 12px;
display: flex;
flex-direction: column;
height: 100%;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
transition:
transform 120ms ease,
background 120ms ease;
}
.card-media {
flex: 0 0 auto;
}
.card:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.06);
}
.card-media img {
width: 110px;
height: 70px;
border-radius: 10px;
width: 100%;
height: 180px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.1);
display: block;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.card-placeholder {
width: 110px;
height: 70px;
border-radius: 10px;
width: 100%;
height: 180px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.card-meta {
.card-body {
display: flex;
gap: 10px;
align-items: center;
font-size: 12px;
flex: 1;
flex-direction: column;
padding: 0;
}
.card-content {
flex: 1;
padding: 12px 12px 12px;
background: linear-gradient(180deg, rgba(15, 27, 56, 0.75), rgba(11, 16, 32, 0.32));
}
.card-title {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.35;
margin: 0 0 8px;
font-size: 15px;
line-height: 1.25;
letter-spacing: -0.01em;
}
.card-summary {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.card-footer {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(11, 16, 32, 0.45);
font-size: 12px;
}
.card-footer .card-views {
flex: 1;
text-align: center;
}
.pill {
@@ -326,6 +322,10 @@ a {
border-color: rgba(255, 205, 74, 0.35);
}
.pill-blog {
border-color: rgba(140, 88, 255, 0.35);
}
.empty {
padding: 16px;
border-radius: 14px;
@@ -351,12 +351,8 @@ a {
.blog-grid {
grid-template-columns: 1fr;
}
.card {
grid-template-columns: 90px 1fr;
}
.card-media img,
.card-placeholder {
width: 90px;
height: 60px;
height: 200px;
}
}

View File

@@ -0,0 +1,22 @@
import "dotenv/config";
import { createCacheFromEnv } from "../src/lib/cache";
function log(msg: string) {
// eslint-disable-next-line no-console
console.log(`[cache-clear] ${msg}`);
}
async function main() {
const cache = await createCacheFromEnv(process.env, { namespace: "fast-website", log });
await cache.flush();
await cache.close();
log("ok");
}
main().catch((e) => {
// eslint-disable-next-line no-console
console.error(`[cache-clear] failed: ${String(e)}`);
process.exitCode = 1;
});

View File

@@ -4,6 +4,8 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { getIngestConfigFromEnv } from "../src/lib/config";
import { createCacheFromEnv } from "../src/lib/cache";
import { cachedCompute } from "../src/lib/cache/memoize";
import type { ContentCache, ContentItem } from "../src/lib/content/types";
import { readInstagramEmbedPosts } from "../src/lib/ingest/instagram";
import { fetchPodcastRss } from "../src/lib/ingest/podcast";
@@ -42,6 +44,11 @@ async function main() {
const all: ContentItem[] = [];
const outPath = path.join(process.cwd(), "content", "cache", "content.json");
const kv = await createCacheFromEnv(process.env, {
namespace: "fast-website",
log,
});
// Read the existing cache so we can keep last-known-good sections if a source fails.
let existing: ContentCache | undefined;
try {
@@ -56,17 +63,29 @@ async function main() {
log("YouTube: skipped (missing YOUTUBE_CHANNEL_ID)");
} else if (cfg.youtubeApiKey) {
try {
const items = await fetchYoutubeViaApi(cfg.youtubeChannelId, cfg.youtubeApiKey, 25);
const cacheKey = `youtube:api:${cfg.youtubeChannelId}:25`;
const { value: items, cached } = await cachedCompute(kv, cacheKey, () =>
fetchYoutubeViaApi(cfg.youtubeChannelId!, cfg.youtubeApiKey!, 25),
);
log(`YouTube: API ${cached ? "cache" : "live"} (${items.length} items)`);
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);
const cacheKey = `youtube:rss:${cfg.youtubeChannelId}:25`;
const { value: items, cached } = await cachedCompute(kv, cacheKey, () =>
fetchYoutubeViaRss(cfg.youtubeChannelId!, 25),
);
log(`YouTube: RSS ${cached ? "cache" : "live"} (${items.length} items)`);
log(`YouTube: RSS ok (${items.length} items)`);
all.push(...items);
}
} else {
const items = await fetchYoutubeViaRss(cfg.youtubeChannelId, 25);
const cacheKey = `youtube:rss:${cfg.youtubeChannelId}:25`;
const { value: items, cached } = await cachedCompute(kv, cacheKey, () =>
fetchYoutubeViaRss(cfg.youtubeChannelId!, 25),
);
log(`YouTube: RSS ${cached ? "cache" : "live"} (${items.length} items)`);
log(`YouTube: RSS ok (${items.length} items)`);
all.push(...items);
}
@@ -76,7 +95,11 @@ async function main() {
log("Podcast: skipped (missing PODCAST_RSS_URL)");
} else {
try {
const items = await fetchPodcastRss(cfg.podcastRssUrl, 50);
const cacheKey = `podcast:rss:${cfg.podcastRssUrl}:50`;
const { value: items, cached } = await cachedCompute(kv, cacheKey, () =>
fetchPodcastRss(cfg.podcastRssUrl!, 50),
);
log(`Podcast: RSS ${cached ? "cache" : "live"} (${items.length} items)`);
log(`Podcast: RSS ok (${items.length} items)`);
all.push(...items);
} catch (e) {
@@ -103,11 +126,17 @@ async function main() {
wordpress = existing?.wordpress || wordpress;
} else {
try {
const wp = await fetchWordpressContent({
baseUrl: cfg.wordpressBaseUrl,
const cacheKey = `wp:content:${cfg.wordpressBaseUrl}`;
const { value: wp, cached } = await cachedCompute(kv, cacheKey, () =>
fetchWordpressContent({
baseUrl: cfg.wordpressBaseUrl!,
username: cfg.wordpressUsername,
appPassword: cfg.wordpressAppPassword,
});
}),
);
log(
`WordPress: wp-json ${cached ? "cache" : "live"} (${wp.posts.length} posts, ${wp.pages.length} pages, ${wp.categories.length} categories)`,
);
wordpress = wp;
log(
`WordPress: wp-json ok (${wp.posts.length} posts, ${wp.pages.length} pages, ${wp.categories.length} categories)`,
@@ -119,14 +148,16 @@ async function main() {
}
}
const cache: ContentCache = {
const contentCache: ContentCache = {
generatedAt,
items: dedupe(all),
wordpress,
};
await writeAtomic(outPath, JSON.stringify(cache, null, 2));
log(`Wrote cache: ${outPath} (${cache.items.length} total items)`);
await writeAtomic(outPath, JSON.stringify(contentCache, null, 2));
log(`Wrote cache: ${outPath} (${contentCache.items.length} total items)`);
await kv.close();
}
main().catch((e) => {

View File

@@ -1,25 +1,44 @@
---
import type { WordpressPost } from "../lib/content/types";
import StandardCard from "./StandardCard.astro";
type Props = {
post: WordpressPost;
placement: string;
targetId: string;
};
const { post } = Astro.props;
const { post, placement, targetId } = Astro.props;
function truncate(s: string, n: number) {
if (!s) return "";
const t = s.trim();
const t = (s || "").trim();
if (!t) return "";
if (t.length <= n) return t;
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}…`;
}
const d = new Date(post.publishedAt);
const dateLabel = Number.isFinite(d.valueOf())
? d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
: "";
---
<a class="blog-card" href={`/blog/post/${post.slug}`}>
{post.featuredImageUrl ? <img src={post.featuredImageUrl} alt="" loading="lazy" /> : null}
<div class="blog-card-body">
<h3 class="blog-card-title">{post.title}</h3>
<p class="blog-card-excerpt">{truncate(post.excerpt || "", 180)}</p>
</div>
</a>
<StandardCard
href={`/blog/post/${post.slug}`}
title={post.title}
summary={post.excerpt}
imageUrl={post.featuredImageUrl}
dateLabel={dateLabel}
viewsLabel={undefined}
sourceLabel="blog"
isExternal={false}
linkAttrs={{
"data-umami-event": "click",
"data-umami-event-target_id": targetId,
"data-umami-event-placement": placement,
"data-umami-event-target_url": `/blog/post/${post.slug}`,
"data-umami-event-title": truncate(post.title, 160),
"data-umami-event-type": "blog_post",
}}
/>

View File

@@ -10,16 +10,36 @@ const { categories, activeCategorySlug } = Astro.props;
---
<nav class="subnav" aria-label="Blog categories">
<a class={!activeCategorySlug ? "active" : ""} href="/blog">
<a
class={!activeCategorySlug ? "active" : ""}
href="/blog"
data-umami-event="click"
data-umami-event-target_id="blog.subnav.all"
data-umami-event-placement="blog.subnav"
data-umami-event-target_url="/blog"
>
All
</a>
<a class={activeCategorySlug === "__pages" ? "active" : ""} href="/blog/pages">
<a
class={activeCategorySlug === "__pages" ? "active" : ""}
href="/blog/pages"
data-umami-event="click"
data-umami-event-target_id="blog.subnav.pages"
data-umami-event-placement="blog.subnav"
data-umami-event-target_url="/blog/pages"
>
Pages
</a>
{categories.map((c) => (
<a class={activeCategorySlug === c.slug ? "active" : ""} href={`/blog/category/${c.slug}`}>
<a
class={activeCategorySlug === c.slug ? "active" : ""}
href={`/blog/category/${c.slug}`}
data-umami-event="click"
data-umami-event-target_id={`blog.subnav.category.${c.slug}`}
data-umami-event-placement="blog.subnav"
data-umami-event-target_url={`/blog/category/${c.slug}`}
>
{c.name}
</a>
))}
</nav>

View File

@@ -1,5 +1,6 @@
---
import type { ContentItem } from "../lib/content/types";
import StandardCard from "./StandardCard.astro";
type Props = {
item: ContentItem;
@@ -7,6 +8,12 @@ type Props = {
};
const { item, placement } = Astro.props;
function truncate(s: string, n: number) {
const t = (s || "").trim();
if (t.length <= n) return t;
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}…`;
}
const d = new Date(item.publishedAt);
const dateLabel = Number.isFinite(d.valueOf())
? d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
@@ -19,40 +26,35 @@ try {
} catch {
domain = "";
}
const umamiType =
item.source === "youtube"
? "video"
: item.source === "podcast"
? "podcast_episode"
: undefined;
const umamiTitle = umamiType ? truncate(item.title, 160) : undefined;
---
<a
class="card"
<StandardCard
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>
title={item.title}
summary={item.summary}
imageUrl={item.thumbnailUrl}
dateLabel={dateLabel}
viewsLabel={item.metrics?.views !== undefined ? `${item.metrics.views.toLocaleString()} views` : undefined}
sourceLabel={item.source}
isExternal={true}
linkAttrs={{
"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-title": umamiTitle,
"data-umami-event-type": umamiType,
"data-umami-event-domain": domain || "unknown",
"data-umami-event-source": item.source,
"data-umami-event-ui_placement": "content_card",
}}
/>

View File

@@ -0,0 +1,63 @@
---
type Props = {
href: string;
title: string;
summary?: string;
imageUrl?: string;
dateLabel?: string;
viewsLabel?: string;
sourceLabel: string;
isExternal?: boolean;
linkAttrs?: Record<string, any>;
};
const {
href,
title,
summary,
imageUrl,
dateLabel,
viewsLabel,
sourceLabel,
isExternal,
linkAttrs,
} = Astro.props;
function truncate(s: string, n: number) {
const t = (s || "").trim();
if (!t) return "";
if (t.length <= n) return t;
// ASCII ellipsis to avoid encoding issues in generated HTML.
return `${t.slice(0, Math.max(0, n - 3)).trimEnd()}...`;
}
const summaryText = truncate(summary || "", 180);
---
<a
class="card"
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
{...(linkAttrs || {})}
>
<div class="card-media">
{imageUrl ? <img src={imageUrl} alt="" loading="lazy" /> : <div class="card-placeholder" />}
</div>
<div class="card-body">
<div class="card-content">
<h3 class="card-title">{title}</h3>
{summaryText ? <p class="card-summary">{summaryText}</p> : null}
</div>
<div class="card-footer">
<span class="muted card-date">{dateLabel || ""}</span>
<span class="muted card-views" aria-hidden={viewsLabel ? undefined : "true"}>
{viewsLabel || ""}
</span>
<span class={`pill pill-${sourceLabel}`}>{sourceLabel}</span>
</div>
</div>
</a>

28
site/src/lib/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,28 @@
import type { CacheLogFn, CacheStore } from "./redis-cache";
import {
createRedisCache,
resolveDefaultTtlSecondsFromEnv,
resolveRedisUrlFromEnv,
} from "./redis-cache";
import { createNoopCache } from "./noop-cache";
export async function createCacheFromEnv(
env: NodeJS.ProcessEnv,
opts?: { namespace?: string; log?: CacheLogFn },
): Promise<CacheStore> {
const url = resolveRedisUrlFromEnv(env);
if (!url) return createNoopCache(opts?.log);
try {
return await createRedisCache({
url,
defaultTtlSeconds: resolveDefaultTtlSecondsFromEnv(env),
namespace: opts?.namespace,
log: opts?.log,
});
} catch (e) {
opts?.log?.(`cache: disabled (redis connect failed: ${String(e)})`);
return createNoopCache(opts?.log);
}
}

16
site/src/lib/cache/memoize.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
import type { CacheStore } from "./redis-cache";
export async function cachedCompute<T>(
cache: CacheStore,
key: string,
compute: () => Promise<T>,
ttlSeconds?: number,
): Promise<{ value: T; cached: boolean }> {
const hit = await cache.getJson<T>(key);
if (hit !== undefined) return { value: hit, cached: true };
const value = await compute();
await cache.setJson(key, value, ttlSeconds);
return { value, cached: false };
}

49
site/src/lib/cache/memory-cache.ts vendored Normal file
View File

@@ -0,0 +1,49 @@
import type { CacheStore } from "./redis-cache";
type Entry = { value: string; expiresAt: number };
export function createMemoryCache(defaultTtlSeconds: number): CacheStore {
const store = new Map<string, Entry>();
function nowMs() {
return Date.now();
}
function isExpired(e: Entry) {
return e.expiresAt !== 0 && nowMs() > e.expiresAt;
}
return {
async getJson<T>(key: string) {
const e = store.get(key);
if (!e) return undefined;
if (isExpired(e)) {
store.delete(key);
return undefined;
}
try {
return JSON.parse(e.value) as T;
} catch {
store.delete(key);
return undefined;
}
},
async setJson(key: string, value: unknown, ttlSeconds?: number) {
const ttl = Math.max(1, Math.floor(ttlSeconds ?? defaultTtlSeconds));
store.set(key, {
value: JSON.stringify(value),
expiresAt: nowMs() + ttl * 1000,
});
},
async flush() {
store.clear();
},
async close() {
// no-op
},
};
}

19
site/src/lib/cache/noop-cache.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
import type { CacheLogFn, CacheStore } from "./redis-cache";
export function createNoopCache(log?: CacheLogFn): CacheStore {
return {
async getJson() {
return undefined;
},
async setJson() {
// no-op
},
async flush() {
log?.("cache: noop flush");
},
async close() {
// no-op
},
};
}

92
site/src/lib/cache/redis-cache.ts vendored Normal file
View File

@@ -0,0 +1,92 @@
import { createClient } from "redis";
export type CacheLogFn = (msg: string) => void;
export type CacheStore = {
getJson<T>(key: string): Promise<T | undefined>;
setJson(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
flush(): Promise<void>;
close(): Promise<void>;
};
type RedisCacheOptions = {
url: string;
defaultTtlSeconds: number;
namespace?: string;
log?: CacheLogFn;
};
function nsKey(namespace: string | undefined, key: string) {
return namespace ? `${namespace}:${key}` : key;
}
export async function createRedisCache(opts: RedisCacheOptions): Promise<CacheStore> {
const log = opts.log;
const client = createClient({ url: opts.url });
client.on("error", (err) => {
log?.(`cache: redis error (${String(err)})`);
});
await client.connect();
return {
async getJson<T>(key: string) {
const k = nsKey(opts.namespace, key);
const raw = await client.get(k);
if (raw == null) {
log?.(`cache: miss ${k}`);
return undefined;
}
log?.(`cache: hit ${k}`);
try {
return JSON.parse(raw) as T;
} catch {
// Bad cache entry: treat as miss.
return undefined;
}
},
async setJson(key: string, value: unknown, ttlSeconds?: number) {
const k = nsKey(opts.namespace, key);
const ttl = Math.max(1, Math.floor(ttlSeconds ?? opts.defaultTtlSeconds));
const raw = JSON.stringify(value);
await client.set(k, raw, { EX: ttl });
},
async flush() {
await client.flushDb();
},
async close() {
try {
await client.quit();
} catch {
// ignore
}
},
};
}
export function resolveRedisUrlFromEnv(env: NodeJS.ProcessEnv): string | undefined {
const url = env.CACHE_REDIS_URL;
if (url) return url;
const host = env.CACHE_REDIS_HOST;
const port = env.CACHE_REDIS_PORT;
const db = env.CACHE_REDIS_DB;
if (!host) return undefined;
const p = port ? Number(port) : 6379;
const d = db ? Number(db) : 0;
if (!Number.isFinite(p) || !Number.isFinite(d)) return undefined;
return `redis://${host}:${p}/${d}`;
}
export function resolveDefaultTtlSecondsFromEnv(env: NodeJS.ProcessEnv): number {
const raw = env.CACHE_DEFAULT_TTL_SECONDS;
const n = raw ? Number(raw) : NaN;
if (Number.isFinite(n) && n > 0) return Math.floor(n);
return 3600;
}

View File

@@ -14,6 +14,11 @@ type IngestConfig = {
wordpressBaseUrl?: string;
wordpressUsername?: string;
wordpressAppPassword?: string;
cacheRedisUrl?: string;
cacheRedisHost?: string;
cacheRedisPort?: number;
cacheRedisDb?: number;
cacheDefaultTtlSeconds?: number;
};
export function getPublicConfig(): PublicConfig {
@@ -37,5 +42,12 @@ export function getIngestConfigFromEnv(env: NodeJS.ProcessEnv): IngestConfig {
wordpressBaseUrl: env.WORDPRESS_BASE_URL,
wordpressUsername: env.WORDPRESS_USERNAME,
wordpressAppPassword: env.WORDPRESS_APP_PASSWORD,
cacheRedisUrl: env.CACHE_REDIS_URL,
cacheRedisHost: env.CACHE_REDIS_HOST,
cacheRedisPort: env.CACHE_REDIS_PORT ? Number(env.CACHE_REDIS_PORT) : undefined,
cacheRedisDb: env.CACHE_REDIS_DB ? Number(env.CACHE_REDIS_DB) : undefined,
cacheDefaultTtlSeconds: env.CACHE_DEFAULT_TTL_SECONDS
? Number(env.CACHE_DEFAULT_TTL_SECONDS)
: undefined,
};
}

View File

@@ -9,6 +9,7 @@ export type ContentItem = {
source: ContentSource;
url: string;
title: string;
summary?: string;
publishedAt: string; // ISO-8601
thumbnailUrl?: string;
metrics?: ContentMetrics;

View File

@@ -8,16 +8,40 @@ export async function fetchPodcastRss(rssUrl: string, limit = 50): Promise<Conte
return normalizePodcastFeedItems(feed.items || [], limit);
}
function stripHtml(s: string) {
return (s || "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function truncate(s: string, n: number) {
const t = stripHtml(s);
if (!t) return "";
if (t.length <= n) return t;
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}`;
}
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();
const summary = truncate(
(it.contentSnippet ||
it.summary ||
it.content ||
it["content:encoded"] ||
it.itunes?.subtitle ||
"").toString(),
240,
);
return {
id,
source: "podcast" as const,
url,
title: (it.title || "").toString(),
summary: summary || undefined,
publishedAt: new Date(publishedAt).toISOString(),
thumbnailUrl: (it.itunes?.image || undefined) as string | undefined,
};

View File

@@ -6,11 +6,26 @@ type YoutubeApiVideo = {
id: string;
url: string;
title: string;
summary?: string;
publishedAt: string;
thumbnailUrl?: string;
views?: number;
};
function stripHtml(s: string) {
return (s || "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function truncate(s: string, n: number) {
const t = stripHtml(s);
if (!t) return "";
if (t.length <= n) return t;
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}`;
}
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();
@@ -32,11 +47,16 @@ export function normalizeYoutubeRssFeedItems(items: any[], limit: number): Conte
const url = it.link || "";
const id = (it.id || url).toString();
const publishedAt = (it.isoDate || it.pubDate || new Date(0).toISOString()).toString();
const summary = truncate(
(it.contentSnippet || it.summary || it.content || it["content:encoded"] || "").toString(),
240,
);
return {
id,
source: "youtube" as const,
url,
title: (it.title || "").toString(),
summary: summary || undefined,
publishedAt: new Date(publishedAt).toISOString(),
thumbnailUrl: (it.enclosure?.url || undefined) as string | undefined,
};
@@ -47,7 +67,12 @@ export function normalizeYoutubeRssFeedItems(items: any[], limit: number): Conte
export function normalizeYoutubeApiVideos(
items: Array<{
id: string;
snippet: { title: string; publishedAt: string; thumbnails?: Record<string, { url: string }> };
snippet: {
title: string;
description?: string;
publishedAt: string;
thumbnails?: Record<string, { url: string }>;
};
statistics?: { viewCount?: string };
}>,
): ContentItem[] {
@@ -55,6 +80,7 @@ export function normalizeYoutubeApiVideos(
id: v.id,
url: `https://www.youtube.com/watch?v=${encodeURIComponent(v.id)}`,
title: v.snippet.title,
summary: v.snippet.description ? truncate(v.snippet.description, 240) : undefined,
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,
@@ -65,6 +91,7 @@ export function normalizeYoutubeApiVideos(
source: "youtube",
url: v.url,
title: v.title,
summary: v.summary,
publishedAt: v.publishedAt,
thumbnailUrl: v.thumbnailUrl,
metrics: v.views !== undefined ? { views: v.views } : undefined,

View File

@@ -44,7 +44,11 @@ if (!activeCategory) {
{posts.length > 0 ? (
<div class="blog-grid">
{posts.map((p) => (
<BlogPostCard post={p} />
<BlogPostCard
post={p}
placement={`blog.category.${activeCategory.slug}`}
targetId={`blog.category.${activeCategory.slug}.card.post.${p.slug}`}
/>
))}
</div>
) : (

View File

@@ -25,7 +25,7 @@ const pages = wordpressPages(cache);
{posts.length > 0 ? (
<div class="blog-grid">
{posts.map((p) => (
<BlogPostCard post={p} />
<BlogPostCard post={p} placement="blog.index" targetId={`blog.index.card.post.${p.slug}`} />
))}
</div>
) : (
@@ -37,18 +37,34 @@ const pages = wordpressPages(cache);
<section class="section">
<div class="section-header">
<h2>Pages</h2>
<a class="muted" href="/blog/pages">
<a
class="muted"
href="/blog/pages"
data-umami-event="click"
data-umami-event-target_id="blog.index.pages.browse"
data-umami-event-placement="blog.index.pages_preview"
data-umami-event-target_url="/blog/pages"
>
Browse pages →
</a>
</div>
<div class="empty">
{pages.slice(0, 6).map((p) => (
<div>
<a href={`/blog/page/${p.slug}`}>{p.title}</a>
<a
href={`/blog/page/${p.slug}`}
data-umami-event="click"
data-umami-event-target_id={`blog.index.pages.link.${p.slug}`}
data-umami-event-placement="blog.index.pages_preview"
data-umami-event-target_url={`/blog/page/${p.slug}`}
data-umami-event-title={p.title}
data-umami-event-type="blog_page"
>
{p.title}
</a>
</div>
))}
</div>
</section>
) : null}
</BlogLayout>

View File

@@ -33,7 +33,16 @@ const metaDescription = (page.excerpt || "").slice(0, 160);
<section class="section">
<div class="section-header">
<h2 style="margin: 0;">{page.title}</h2>
<a class="muted" href="/blog">Back →</a>
<a
class="muted"
href="/blog"
data-umami-event="click"
data-umami-event-target_id="blog.page.back"
data-umami-event-placement="blog.page"
data-umami-event-target_url="/blog"
>
Back →
</a>
</div>
{page.featuredImageUrl ? (
<img
@@ -46,4 +55,3 @@ const metaDescription = (page.excerpt || "").slice(0, 160);
<div class="prose" set:html={page.contentHtml} />
</section>
</BlogLayout>

View File

@@ -25,7 +25,17 @@ const pages = wordpressPages(cache);
<div class="empty">
{pages.map((p) => (
<div style="padding: 6px 0;">
<a href={`/blog/page/${p.slug}`}>{p.title}</a>
<a
href={`/blog/page/${p.slug}`}
data-umami-event="click"
data-umami-event-target_id={`blog.pages.link.${p.slug}`}
data-umami-event-placement="blog.pages.list"
data-umami-event-target_url={`/blog/page/${p.slug}`}
data-umami-event-title={p.title}
data-umami-event-type="blog_page"
>
{p.title}
</a>
</div>
))}
</div>
@@ -34,4 +44,3 @@ const pages = wordpressPages(cache);
)}
</section>
</BlogLayout>

View File

@@ -33,7 +33,16 @@ const metaDescription = (post.excerpt || "").slice(0, 160);
<section class="section">
<div class="section-header">
<h2 style="margin: 0;">{post.title}</h2>
<a class="muted" href="/blog">Back →</a>
<a
class="muted"
href="/blog"
data-umami-event="click"
data-umami-event-target_id="blog.post.back"
data-umami-event-placement="blog.post"
data-umami-event-target_url="/blog"
>
Back →
</a>
</div>
<p class="muted" style="margin-top: 0;">
{new Date(post.publishedAt).toLocaleDateString()}
@@ -49,4 +58,3 @@ const metaDescription = (post.excerpt || "").slice(0, 160);
<div class="prose" set:html={post.contentHtml} />
</section>
</BlogLayout>

View File

@@ -25,6 +25,12 @@ try {
} catch {
episodeDomain = "";
}
function truncate(s: string, n: number) {
const t = (s || "").trim();
if (t.length <= n) return t;
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}…`;
}
---
<BaseLayout
@@ -62,6 +68,8 @@ try {
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-title={truncate(episode.title, 160)}
data-umami-event-type="podcast_episode"
data-umami-event-domain={episodeDomain || "unknown"}
data-umami-event-source="podcast"
data-umami-event-ui_placement="podcast_detail"

View File

@@ -26,6 +26,12 @@ try {
} catch {
videoDomain = "";
}
function truncate(s: string, n: number) {
const t = (s || "").trim();
if (t.length <= n) return t;
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}…`;
}
---
<BaseLayout
@@ -63,6 +69,8 @@ try {
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-title={truncate(video.title, 160)}
data-umami-event-type="video"
data-umami-event-domain={videoDomain || "unknown"}
data-umami-event-source="youtube"
data-umami-event-ui_placement="video_detail"

View File

@@ -0,0 +1,63 @@
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("blog umami event attributes", () => {
it("instruments blog secondary nav links", async () => {
const src = await read("src/components/BlogSecondaryNav.astro");
expect(src).toContain('data-umami-event="click"');
expect(src).toContain('data-umami-event-target_id="blog.subnav.all"');
expect(src).toContain('data-umami-event-target_id="blog.subnav.pages"');
expect(src).toContain("data-umami-event-target_id={`blog.subnav.category.${c.slug}`}");
expect(src).toContain('data-umami-event-placement="blog.subnav"');
expect(src).toContain("data-umami-event-target_url");
});
it("instruments blog post cards with deterministic target_id and placement", async () => {
const src = await read("src/components/BlogPostCard.astro");
expect(src).toContain('"data-umami-event": "click"');
expect(src).toContain('"data-umami-event-target_id": targetId');
expect(src).toContain('"data-umami-event-placement": placement');
expect(src).toContain("data-umami-event-target_url");
});
it("instruments blog pages list links (and keeps distinct IDs per placement)", async () => {
const indexSrc = await read("src/pages/blog/index.astro");
expect(indexSrc).toContain('data-umami-event-target_id="blog.index.pages.browse"');
expect(indexSrc).toContain("data-umami-event-target_id={`blog.index.pages.link.${p.slug}`}");
expect(indexSrc).toContain('data-umami-event-placement="blog.index.pages_preview"');
const pagesSrc = await read("src/pages/blog/pages.astro");
expect(pagesSrc).toContain("data-umami-event-target_id={`blog.pages.link.${p.slug}`}");
expect(pagesSrc).toContain('data-umami-event-placement="blog.pages.list"');
});
it("instruments blog detail back links", async () => {
const postSrc = await read("src/pages/blog/post/[slug].astro");
expect(postSrc).toContain('data-umami-event-target_id="blog.post.back"');
expect(postSrc).toContain('data-umami-event-placement="blog.post"');
expect(postSrc).toContain('data-umami-event-target_url="/blog"');
const pageSrc = await read("src/pages/blog/page/[slug].astro");
expect(pageSrc).toContain('data-umami-event-target_id="blog.page.back"');
expect(pageSrc).toContain('data-umami-event-placement="blog.page"');
expect(pageSrc).toContain('data-umami-event-target_url="/blog"');
});
it("uses placement-specific target_id for post cards", async () => {
const indexSrc = await read("src/pages/blog/index.astro");
expect(indexSrc).toContain("targetId={`blog.index.card.post.${p.slug}`}");
expect(indexSrc).toContain('placement="blog.index"');
const categorySrc = await read("src/pages/blog/category/[slug].astro");
expect(categorySrc).toContain("placement={`blog.category.${activeCategory.slug}`}");
expect(categorySrc).toContain("targetId={`blog.category.${activeCategory.slug}.card.post.${p.slug}`}");
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { createMemoryCache } from "../src/lib/cache/memory-cache";
import { cachedCompute } from "../src/lib/cache/memoize";
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
describe("cache wrapper", () => {
it("set/get JSON and expires by TTL", async () => {
const cache = createMemoryCache(1);
await cache.setJson("k", { a: 1 }, 1);
const v1 = await cache.getJson<{ a: number }>("k");
expect(v1).toEqual({ a: 1 });
await sleep(1100);
const v2 = await cache.getJson("k");
expect(v2).toBeUndefined();
});
it("cachedCompute hits on second call within TTL", async () => {
const cache = createMemoryCache(60);
let calls = 0;
const compute = async () => {
calls++;
return { ok: true, n: calls };
};
const r1 = await cachedCompute(cache, "x", compute, 60);
const r2 = await cachedCompute(cache, "x", compute, 60);
expect(r1.cached).toBe(false);
expect(r2.cached).toBe(true);
expect(calls).toBe(1);
expect(r2.value).toEqual(r1.value);
});
});

View File

@@ -0,0 +1,29 @@
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("card layout", () => {
it("uses a shared StandardCard component for content and blog cards", async () => {
const contentCard = await read("src/components/ContentCard.astro");
expect(contentCard).toContain('import StandardCard from "./StandardCard.astro";');
expect(contentCard).toContain("<StandardCard");
const blogPostCard = await read("src/components/BlogPostCard.astro");
expect(blogPostCard).toContain('import StandardCard from "./StandardCard.astro";');
expect(blogPostCard).toContain("<StandardCard");
});
it("defines standardized card CSS (content band + footer bar)", async () => {
const css = await read("public/styles/global.css");
expect(css).toContain(".card-content");
expect(css).toContain("justify-content: space-between");
expect(css).toContain(".card-footer");
expect(css).toContain(".card-summary");
});
});

View File

@@ -0,0 +1,45 @@
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("content link umami title/type attributes", () => {
it("adds title/type on outbound content cards (youtube/podcast) without forcing instagram", async () => {
const src = await read("src/components/ContentCard.astro");
expect(src).toContain("data-umami-event-title");
expect(src).toContain("data-umami-event-type");
expect(src).toContain('item.source === "youtube"');
expect(src).toContain('item.source === "podcast"');
});
it("adds title/type on video detail outbound CTA", async () => {
const src = await read("src/pages/videos/[id].astro");
expect(src).toContain('data-umami-event-title={truncate(video.title, 160)}');
expect(src).toContain('data-umami-event-type="video"');
});
it("adds title/type on podcast detail outbound CTA", async () => {
const src = await read("src/pages/podcast/[id].astro");
expect(src).toContain('data-umami-event-title={truncate(episode.title, 160)}');
expect(src).toContain('data-umami-event-type="podcast_episode"');
});
it("adds title/type on blog post cards and pages links", async () => {
const cardSrc = await read("src/components/BlogPostCard.astro");
expect(cardSrc).toContain('"data-umami-event-type": "blog_post"');
expect(cardSrc).toContain("data-umami-event-title");
const blogIndexSrc = await read("src/pages/blog/index.astro");
expect(blogIndexSrc).toContain('data-umami-event-type="blog_page"');
expect(blogIndexSrc).toContain("data-umami-event-title={p.title}");
const blogPagesSrc = await read("src/pages/blog/pages.astro");
expect(blogPagesSrc).toContain('data-umami-event-type="blog_page"');
expect(blogPagesSrc).toContain("data-umami-event-title={p.title}");
});
});

View File

@@ -24,7 +24,7 @@ describe("umami event attributes", () => {
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": "outbound_click"');
expect(src).toContain("data-umami-event-target_id");
expect(src).toContain("data-umami-event-domain");
});