2
openspec/changes/blogs-section/.openspec.yaml
Normal file
2
openspec/changes/blogs-section/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-10
|
||||||
53
openspec/changes/blogs-section/design.md
Normal file
53
openspec/changes/blogs-section/design.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The site is currently a static Astro build served via nginx. Content is populated by a build-time fetch step (`site/scripts/fetch-content.ts`) that writes a repo-local cache file consumed by the Astro pages/components.
|
||||||
|
|
||||||
|
We want to add a new Blog section backed by a WordPress site via the `wp-json` REST APIs, including:
|
||||||
|
- a primary header nav link (`/blog`)
|
||||||
|
- blog listing pages (cards with featured image, title, excerpt)
|
||||||
|
- blog detail pages (full content)
|
||||||
|
- a blog-only secondary navigation based on WordPress categories
|
||||||
|
- support for both WordPress posts and pages
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Add `/blog` with a listing of WordPress posts rendered as static HTML at build time.
|
||||||
|
- Add detail pages for WordPress posts and pages, rendered as static HTML at build time.
|
||||||
|
- Add category-based browsing within the Blog section (secondary navigation + category listing pages).
|
||||||
|
- Use environment variables for WordPress configuration (site URL and credentials) and fetch via `wp-json`.
|
||||||
|
- Keep pages indexable and included in sitemap output.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Real-time updates without rebuilds (v1 remains build-time fetched).
|
||||||
|
- Implementing “like” storage in WordPress or a database (nice-to-have can be a simple outbound share action later).
|
||||||
|
- Full WordPress theme parity (we render a simplified reading surface).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Decision: Build-time ingestion into the existing content cache.**
|
||||||
|
- Rationale: Matches the current architecture (cache file + static build), keeps the site fast and crawlable, and avoids introducing a runtime server layer.
|
||||||
|
- Alternative: Client-side fetch from WP directly. Rejected for SEO and performance (would rely on client rendering and adds CORS/auth complexity).
|
||||||
|
|
||||||
|
- **Decision: Prefer WordPress Application Passwords over raw user passwords (if possible).**
|
||||||
|
- Rationale: Application passwords are the standard WP approach for API access and can be revoked without changing the user login password.
|
||||||
|
- Alternative: Basic auth with username/password. Allowed if that’s what your WP setup supports, but we should treat credentials as secrets in `.env`.
|
||||||
|
|
||||||
|
- **Decision: Normalize WordPress content into a small internal schema.**
|
||||||
|
- Rationale: Keeps UI components simple and consistent with existing content rendering patterns (cards + detail pages).
|
||||||
|
- Implementation: Add a `wordpress` source to the cache schema, with fields for `id`, `slug`, `kind` (`post|page`), `title`, `excerpt`, `contentHtml`, `featuredImageUrl`, `publishedAt`, `updatedAt`, `categories`.
|
||||||
|
|
||||||
|
- **Decision: Route structure.**
|
||||||
|
- Rationale: Keep URLs clear and stable.
|
||||||
|
- Proposed:
|
||||||
|
- `/blog` (latest posts)
|
||||||
|
- `/blog/category/<slug>` (posts in category)
|
||||||
|
- `/blog/post/<slug>` (post detail)
|
||||||
|
- `/blog/page/<slug>` (page detail)
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Risk] WP API rate limits / downtime break the build. → Mitigation: Cache last-known-good content.json; on fetch failure, retain existing cache and log errors.
|
||||||
|
- [Risk] WordPress HTML content can contain unexpected markup or scripts. → Mitigation: Render server-side as HTML but sanitize or strip scripts; document allowed HTML subset.
|
||||||
|
- [Risk] Auth method differs per WP hosting. → Mitigation: Support both public endpoints for reading (preferred) and authenticated requests when needed; keep config flexible.
|
||||||
|
|
||||||
30
openspec/changes/blogs-section/proposal.md
Normal file
30
openspec/changes/blogs-section/proposal.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Add a blog section so the site can publish indexable textual content (in addition to videos/podcast), improving SEO and giving visitors another reason to return and engage.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a new primary navigation link in the header: **Blog** (between **Podcast** and **About**).
|
||||||
|
- Add a blog index route that lists WordPress posts as cards (featured image, title, excerpt/summary).
|
||||||
|
- Add blog detail routes so a user can read the full content of a post.
|
||||||
|
- Add a secondary navigation within the blog section driven by WordPress categories (exact structure negotiable).
|
||||||
|
- Support rendering both WordPress **posts** and **pages** within the blog section.
|
||||||
|
- Add configuration via environment variables for WordPress site URL and credentials, and fetch content via the WordPress `wp-json` REST APIs.
|
||||||
|
- (Optional / later) Like and share feature for blog content.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `wordpress-content-source`: Fetch posts, pages, and categories from a configured WordPress site via `wp-json`, and provide them in a form the site can render (including featured images and excerpts).
|
||||||
|
- `blog-section-surface`: Provide blog routes (index, category views, content detail pages) and a secondary navigation for blog browsing.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `seo-content-surface`: Include the blog routes in the indexable surface (e.g., sitemap coverage and crawlable HTML for `/blog` and blog detail pages).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Site UI/layout: header navigation update; new blog pages; secondary blog navigation.
|
||||||
|
- Content pipeline: extend the content fetching/caching flow to include WordPress content; update any normalized schemas/types as needed.
|
||||||
|
- Configuration: add WordPress settings to environment/config and ensure they are supported in local dev and Docker.
|
||||||
|
- SEO: ensure blog pages have correct titles, descriptions/excerpts, canonical URLs, and appear in `sitemap.xml`.
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
## 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
|
||||||
|
|
||||||
|
The listing MUST be ordered by publish date descending (newest first).
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Sitemap and robots
|
||||||
|
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`, `/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
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## 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.
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
28
openspec/changes/blogs-section/tasks.md
Normal file
28
openspec/changes/blogs-section/tasks.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
## 1. WordPress Config And Fetch
|
||||||
|
|
||||||
|
- [x] 1.1 Add WordPress env/config variables (base URL + credentials) and document them in `site/.env.example`
|
||||||
|
- [x] 1.2 Extend `site/scripts/fetch-content.ts` to fetch WordPress posts, pages, and categories via `wp-json` and write to `site/content/cache/content.json`
|
||||||
|
- [x] 1.3 Add a failure mode where WP fetch errors do not crash the whole fetch/build (keep last-known-good or write empty WP dataset)
|
||||||
|
|
||||||
|
## 2. Normalize And Select
|
||||||
|
|
||||||
|
- [x] 2.1 Extend content cache/types to represent WordPress items (post/page) and categories
|
||||||
|
- [x] 2.2 Add selector helpers for WordPress posts/pages/categories (ordered by publish date, filter by category)
|
||||||
|
|
||||||
|
## 3. Blog UI Surface
|
||||||
|
|
||||||
|
- [x] 3.1 Add `/blog` index page that renders WordPress post cards (featured image, title, excerpt)
|
||||||
|
- [x] 3.2 Add post detail routes (e.g., `/blog/post/<slug>`) that render the full post content
|
||||||
|
- [x] 3.3 Add page detail routes (e.g., `/blog/page/<slug>`) that render the full page content
|
||||||
|
- [x] 3.4 Add blog secondary navigation under header based on cached categories, with category listing pages (e.g., `/blog/category/<slug>`)
|
||||||
|
- [x] 3.5 Add header nav link "Blog" between "Podcast" and "About"
|
||||||
|
|
||||||
|
## 4. SEO And Sitemap
|
||||||
|
|
||||||
|
- [x] 4.1 Ensure blog pages include title/description/canonical URL metadata
|
||||||
|
- [x] 4.2 Update sitemap generation to include `/blog` and blog content routes when WP content is present at build time
|
||||||
|
|
||||||
|
## 5. Verification
|
||||||
|
|
||||||
|
- [x] 5.1 Add at least one test to assert the header includes the Blog link
|
||||||
|
- [x] 5.2 Add a build verification that `/blog` is generated and renders an empty state when no WP content is available
|
||||||
@@ -13,3 +13,9 @@ PODCAST_RSS_URL=https://example.com/podcast.rss
|
|||||||
# Instagram embed-first list (JSON file containing {"postUrls":[...]})
|
# Instagram embed-first list (JSON file containing {"postUrls":[...]})
|
||||||
INSTAGRAM_POST_URLS_FILE=content/instagram-posts.json
|
INSTAGRAM_POST_URLS_FILE=content/instagram-posts.json
|
||||||
|
|
||||||
|
# WordPress (optional; used by scripts/fetch-content.ts to build the /blog section)
|
||||||
|
# Example: https://your-site.com (no trailing slash)
|
||||||
|
WORDPRESS_BASE_URL=
|
||||||
|
# Optional credentials (prefer an Application Password). Leave blank if your WP endpoints are public.
|
||||||
|
WORDPRESS_USERNAME=
|
||||||
|
WORDPRESS_APP_PASSWORD=
|
||||||
|
|||||||
349
site/content/cache/content.json
vendored
349
site/content/cache/content.json
vendored
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@
|
|||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"fetch-content": "tsx scripts/fetch-content.ts",
|
"fetch-content": "tsx scripts/fetch-content.ts",
|
||||||
|
"verify:blog": "npm run build && tsx scripts/verify-blog-build.ts",
|
||||||
"typecheck": "astro check",
|
"typecheck": "astro check",
|
||||||
"format": "prettier -w .",
|
"format": "prettier -w .",
|
||||||
"format:check": "prettier -c .",
|
"format:check": "prettier -c .",
|
||||||
|
|||||||
@@ -82,6 +82,95 @@ a {
|
|||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subnav {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subnav a {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subnav a.active {
|
||||||
|
color: var(--fg);
|
||||||
|
border-color: rgba(94, 228, 255, 0.35);
|
||||||
|
box-shadow: 0 0 0 3px rgba(94, 228, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
line-height: 1.75;
|
||||||
|
color: rgba(242, 244, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
@@ -259,6 +348,9 @@ a {
|
|||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.blog-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.card {
|
.card {
|
||||||
grid-template-columns: 90px 1fr;
|
grid-template-columns: 90px 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getIngestConfigFromEnv } from "../src/lib/config";
|
|||||||
import type { ContentCache, ContentItem } from "../src/lib/content/types";
|
import type { ContentCache, ContentItem } from "../src/lib/content/types";
|
||||||
import { readInstagramEmbedPosts } from "../src/lib/ingest/instagram";
|
import { readInstagramEmbedPosts } from "../src/lib/ingest/instagram";
|
||||||
import { fetchPodcastRss } from "../src/lib/ingest/podcast";
|
import { fetchPodcastRss } from "../src/lib/ingest/podcast";
|
||||||
|
import { fetchWordpressContent } from "../src/lib/ingest/wordpress";
|
||||||
import { fetchYoutubeViaApi, fetchYoutubeViaRss } from "../src/lib/ingest/youtube";
|
import { fetchYoutubeViaApi, fetchYoutubeViaRss } from "../src/lib/ingest/youtube";
|
||||||
|
|
||||||
function log(msg: string) {
|
function log(msg: string) {
|
||||||
@@ -39,6 +40,16 @@ async function main() {
|
|||||||
const generatedAt = new Date().toISOString();
|
const generatedAt = new Date().toISOString();
|
||||||
|
|
||||||
const all: ContentItem[] = [];
|
const all: ContentItem[] = [];
|
||||||
|
const outPath = path.join(process.cwd(), "content", "cache", "content.json");
|
||||||
|
|
||||||
|
// Read the existing cache so we can keep last-known-good sections if a source fails.
|
||||||
|
let existing: ContentCache | undefined;
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(outPath, "utf8");
|
||||||
|
existing = JSON.parse(raw) as ContentCache;
|
||||||
|
} catch {
|
||||||
|
existing = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// YouTube
|
// YouTube
|
||||||
if (!cfg.youtubeChannelId) {
|
if (!cfg.youtubeChannelId) {
|
||||||
@@ -85,12 +96,35 @@ async function main() {
|
|||||||
log(`Instagram: embed list failed (${String(e)})`);
|
log(`Instagram: embed list failed (${String(e)})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WordPress (optional; powers /blog)
|
||||||
|
let wordpress: ContentCache["wordpress"] = { posts: [], pages: [], categories: [] };
|
||||||
|
if (!cfg.wordpressBaseUrl) {
|
||||||
|
log("WordPress: skipped (missing WORDPRESS_BASE_URL)");
|
||||||
|
wordpress = existing?.wordpress || wordpress;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const wp = await fetchWordpressContent({
|
||||||
|
baseUrl: cfg.wordpressBaseUrl,
|
||||||
|
username: cfg.wordpressUsername,
|
||||||
|
appPassword: cfg.wordpressAppPassword,
|
||||||
|
});
|
||||||
|
wordpress = wp;
|
||||||
|
log(
|
||||||
|
`WordPress: wp-json ok (${wp.posts.length} posts, ${wp.pages.length} pages, ${wp.categories.length} categories)`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
log(`WordPress: wp-json failed (${String(e)})`);
|
||||||
|
// Keep last-known-good WP content if present, otherwise fall back to empty.
|
||||||
|
wordpress = existing?.wordpress || wordpress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cache: ContentCache = {
|
const cache: ContentCache = {
|
||||||
generatedAt,
|
generatedAt,
|
||||||
items: dedupe(all),
|
items: dedupe(all),
|
||||||
|
wordpress,
|
||||||
};
|
};
|
||||||
|
|
||||||
const outPath = path.join(process.cwd(), "content", "cache", "content.json");
|
|
||||||
await writeAtomic(outPath, JSON.stringify(cache, null, 2));
|
await writeAtomic(outPath, JSON.stringify(cache, null, 2));
|
||||||
log(`Wrote cache: ${outPath} (${cache.items.length} total items)`);
|
log(`Wrote cache: ${outPath} (${cache.items.length} total items)`);
|
||||||
}
|
}
|
||||||
|
|||||||
27
site/scripts/verify-blog-build.ts
Normal file
27
site/scripts/verify-blog-build.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { access, readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { readContentCache } from "../src/lib/content/cache";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const distPath = path.join(process.cwd(), "dist", "blog", "index.html");
|
||||||
|
await access(distPath);
|
||||||
|
|
||||||
|
const html = await readFile(distPath, "utf8");
|
||||||
|
|
||||||
|
const cache = await readContentCache();
|
||||||
|
const posts = cache.wordpress?.posts || [];
|
||||||
|
|
||||||
|
if (posts.length === 0) {
|
||||||
|
if (!html.includes("No blog posts yet.")) {
|
||||||
|
throw new Error("Expected /blog empty state to be present when no WordPress posts exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(String(e));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|
||||||
25
site/src/components/BlogPostCard.astro
Normal file
25
site/src/components/BlogPostCard.astro
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
import type { WordpressPost } from "../lib/content/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
post: WordpressPost;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { post } = Astro.props;
|
||||||
|
|
||||||
|
function truncate(s: string, n: number) {
|
||||||
|
if (!s) return "";
|
||||||
|
const t = s.trim();
|
||||||
|
if (t.length <= n) return t;
|
||||||
|
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
25
site/src/components/BlogSecondaryNav.astro
Normal file
25
site/src/components/BlogSecondaryNav.astro
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
import type { WordpressCategory } from "../lib/content/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
categories: WordpressCategory[];
|
||||||
|
activeCategorySlug?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { categories, activeCategorySlug } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav class="subnav" aria-label="Blog categories">
|
||||||
|
<a class={!activeCategorySlug ? "active" : ""} href="/blog">
|
||||||
|
All
|
||||||
|
</a>
|
||||||
|
<a class={activeCategorySlug === "__pages" ? "active" : ""} href="/blog/pages">
|
||||||
|
Pages
|
||||||
|
</a>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<a class={activeCategorySlug === c.slug ? "active" : ""} href={`/blog/category/${c.slug}`}>
|
||||||
|
{c.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
@@ -79,6 +79,15 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
|
|||||||
>
|
>
|
||||||
Podcast
|
Podcast
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/blog"
|
||||||
|
data-umami-event="click"
|
||||||
|
data-umami-event-target_id="nav.blog"
|
||||||
|
data-umami-event-placement="nav"
|
||||||
|
data-umami-event-target_url="/blog"
|
||||||
|
>
|
||||||
|
Blog
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/about"
|
href="/about"
|
||||||
data-umami-event="click"
|
data-umami-event="click"
|
||||||
|
|||||||
22
site/src/layouts/BlogLayout.astro
Normal file
22
site/src/layouts/BlogLayout.astro
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from "./BaseLayout.astro";
|
||||||
|
import BlogSecondaryNav from "../components/BlogSecondaryNav.astro";
|
||||||
|
import type { WordpressCategory } from "../lib/content/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
canonicalPath: string;
|
||||||
|
ogImageUrl?: string;
|
||||||
|
categories: WordpressCategory[];
|
||||||
|
activeCategorySlug?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { categories, activeCategorySlug, ...rest } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout {...rest}>
|
||||||
|
<BlogSecondaryNav categories={categories} activeCategorySlug={activeCategorySlug} />
|
||||||
|
<slot />
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
@@ -11,6 +11,9 @@ type IngestConfig = {
|
|||||||
youtubeApiKey?: string;
|
youtubeApiKey?: string;
|
||||||
podcastRssUrl?: string;
|
podcastRssUrl?: string;
|
||||||
instagramPostUrlsFile: string;
|
instagramPostUrlsFile: string;
|
||||||
|
wordpressBaseUrl?: string;
|
||||||
|
wordpressUsername?: string;
|
||||||
|
wordpressAppPassword?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPublicConfig(): PublicConfig {
|
export function getPublicConfig(): PublicConfig {
|
||||||
@@ -31,5 +34,8 @@ export function getIngestConfigFromEnv(env: NodeJS.ProcessEnv): IngestConfig {
|
|||||||
youtubeApiKey: env.YOUTUBE_API_KEY,
|
youtubeApiKey: env.YOUTUBE_API_KEY,
|
||||||
podcastRssUrl: env.PODCAST_RSS_URL,
|
podcastRssUrl: env.PODCAST_RSS_URL,
|
||||||
instagramPostUrlsFile: env.INSTAGRAM_POST_URLS_FILE || "content/instagram-posts.json",
|
instagramPostUrlsFile: env.INSTAGRAM_POST_URLS_FILE || "content/instagram-posts.json",
|
||||||
|
wordpressBaseUrl: env.WORDPRESS_BASE_URL,
|
||||||
|
wordpressUsername: env.WORDPRESS_USERNAME,
|
||||||
|
wordpressAppPassword: env.WORDPRESS_APP_PASSWORD,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import path from "node:path";
|
|||||||
|
|
||||||
import type { ContentCache } from "./types";
|
import type { ContentCache } from "./types";
|
||||||
|
|
||||||
const DEFAULT_CACHE: ContentCache = { generatedAt: new Date(0).toISOString(), items: [] };
|
const DEFAULT_CACHE: ContentCache = {
|
||||||
|
generatedAt: new Date(0).toISOString(),
|
||||||
|
items: [],
|
||||||
|
wordpress: { posts: [], pages: [], categories: [] },
|
||||||
|
};
|
||||||
|
|
||||||
function getCachePath() {
|
function getCachePath() {
|
||||||
// Read from the repo-local content cache (populated by scripts/fetch-content.ts).
|
// Read from the repo-local content cache (populated by scripts/fetch-content.ts).
|
||||||
@@ -18,6 +22,10 @@ export async function readContentCache(): Promise<ContentCache> {
|
|||||||
if (!parsed || !Array.isArray(parsed.items) || typeof parsed.generatedAt !== "string") {
|
if (!parsed || !Array.isArray(parsed.items) || typeof parsed.generatedAt !== "string") {
|
||||||
return DEFAULT_CACHE;
|
return DEFAULT_CACHE;
|
||||||
}
|
}
|
||||||
|
// Ensure new optional fields exist (keeps callers simple).
|
||||||
|
if (!parsed.wordpress) {
|
||||||
|
parsed.wordpress = { posts: [], pages: [], categories: [] };
|
||||||
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
// Cache missing is normal for first run.
|
// Cache missing is normal for first run.
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import type { ContentCache, ContentItem, ContentSource } from "./types";
|
import type {
|
||||||
|
ContentCache,
|
||||||
|
ContentItem,
|
||||||
|
ContentSource,
|
||||||
|
WordpressCategory,
|
||||||
|
WordpressPage,
|
||||||
|
WordpressPost,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export function newestItems(
|
export function newestItems(
|
||||||
cache: ContentCache,
|
cache: ContentCache,
|
||||||
@@ -43,3 +50,33 @@ export function highPerformingYoutubeVideos(
|
|||||||
const ranked = [...videos].sort((a, b) => (b.metrics?.views || 0) - (a.metrics?.views || 0));
|
const ranked = [...videos].sort((a, b) => (b.metrics?.views || 0) - (a.metrics?.views || 0));
|
||||||
return ranked.slice(0, Math.max(0, limit));
|
return ranked.slice(0, Math.max(0, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function wordpressPosts(cache: ContentCache): WordpressPost[] {
|
||||||
|
const posts = cache.wordpress?.posts || [];
|
||||||
|
return [...posts].sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordpressPages(cache: ContentCache): WordpressPage[] {
|
||||||
|
const pages = cache.wordpress?.pages || [];
|
||||||
|
return [...pages].sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordpressCategories(cache: ContentCache): WordpressCategory[] {
|
||||||
|
const cats = cache.wordpress?.categories || [];
|
||||||
|
return [...cats].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordpressPostBySlug(cache: ContentCache, slug: string): WordpressPost | undefined {
|
||||||
|
return (cache.wordpress?.posts || []).find((p) => p.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordpressPageBySlug(cache: ContentCache, slug: string): WordpressPage | undefined {
|
||||||
|
return (cache.wordpress?.pages || []).find((p) => p.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordpressPostsByCategorySlug(cache: ContentCache, categorySlug: string): WordpressPost[] {
|
||||||
|
const cats = cache.wordpress?.categories || [];
|
||||||
|
const category = cats.find((c) => c.slug === categorySlug);
|
||||||
|
if (!category) return [];
|
||||||
|
return wordpressPosts(cache).filter((p) => p.categoryIds.includes(category.id));
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,4 +17,41 @@ export type ContentItem = {
|
|||||||
export type ContentCache = {
|
export type ContentCache = {
|
||||||
generatedAt: string; // ISO-8601
|
generatedAt: string; // ISO-8601
|
||||||
items: ContentItem[];
|
items: ContentItem[];
|
||||||
|
wordpress?: WordpressContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WordpressKind = "post" | "page";
|
||||||
|
|
||||||
|
export type WordpressCategory = {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WordpressBase = {
|
||||||
|
id: string;
|
||||||
|
kind: WordpressKind;
|
||||||
|
slug: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
contentHtml: string;
|
||||||
|
featuredImageUrl?: string;
|
||||||
|
publishedAt: string; // ISO-8601
|
||||||
|
updatedAt: string; // ISO-8601
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WordpressPost = WordpressBase & {
|
||||||
|
kind: "post";
|
||||||
|
categoryIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WordpressPage = WordpressBase & {
|
||||||
|
kind: "page";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WordpressContent = {
|
||||||
|
posts: WordpressPost[];
|
||||||
|
pages: WordpressPage[];
|
||||||
|
categories: WordpressCategory[];
|
||||||
};
|
};
|
||||||
|
|||||||
176
site/src/lib/ingest/wordpress.ts
Normal file
176
site/src/lib/ingest/wordpress.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import type { WordpressCategory, WordpressContent, WordpressPage, WordpressPost } from "../content/types";
|
||||||
|
|
||||||
|
type WordpressConfig = {
|
||||||
|
baseUrl: string;
|
||||||
|
username?: string;
|
||||||
|
appPassword?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchResult<T> = {
|
||||||
|
data: T;
|
||||||
|
totalPages?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripHtml(input: string): string {
|
||||||
|
return input.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(input: string): string {
|
||||||
|
// Keep it small: enough for common WP fields.
|
||||||
|
return input
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeHtml(input: string): string {
|
||||||
|
// Remove script blocks entirely.
|
||||||
|
return input.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeaders(cfg: WordpressConfig): Record<string, string> {
|
||||||
|
if (!cfg.username || !cfg.appPassword) return {};
|
||||||
|
const token = Buffer.from(`${cfg.username}:${cfg.appPassword}`, "utf8").toString("base64");
|
||||||
|
return { Authorization: `Basic ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wpFetchJson<T>(url: string, headers: Record<string, string>): Promise<FetchResult<T>> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`WP request failed: ${res.status} ${res.statusText} (${url})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPagesRaw = res.headers.get("x-wp-totalpages");
|
||||||
|
const totalPages = totalPagesRaw ? Number(totalPagesRaw) : undefined;
|
||||||
|
const data = (await res.json()) as T;
|
||||||
|
return { data, totalPages };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllPages<T>(
|
||||||
|
urlForPage: (page: number) => string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const first = await wpFetchJson<T[]>(urlForPage(1), headers);
|
||||||
|
const out = [...first.data];
|
||||||
|
|
||||||
|
const total = first.totalPages && Number.isFinite(first.totalPages) ? first.totalPages : 1;
|
||||||
|
for (let p = 2; p <= total; p++) {
|
||||||
|
const pageRes = await wpFetchJson<T[]>(urlForPage(p), headers);
|
||||||
|
out.push(...pageRes.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wpUrl(baseUrl: string, path: string): string {
|
||||||
|
return `${baseUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFeaturedImageUrl(wpItem: any): string | undefined {
|
||||||
|
const embedded = wpItem?._embedded;
|
||||||
|
const media = embedded?.["wp:featuredmedia"];
|
||||||
|
const first = Array.isArray(media) ? media[0] : undefined;
|
||||||
|
const src = first?.source_url;
|
||||||
|
return typeof src === "string" && src.length > 0 ? src : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCategoryIds(wpItem: any): number[] {
|
||||||
|
const ids = wpItem?.categories;
|
||||||
|
return Array.isArray(ids) ? ids.filter((x) => typeof x === "number") : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWordpressPost(raw: any): WordpressPost {
|
||||||
|
const titleHtml = raw?.title?.rendered || "";
|
||||||
|
const excerptHtml = raw?.excerpt?.rendered || "";
|
||||||
|
const contentHtml = raw?.content?.rendered || "";
|
||||||
|
|
||||||
|
const title = decodeEntities(stripHtml(String(titleHtml)));
|
||||||
|
const excerpt = decodeEntities(stripHtml(String(excerptHtml)));
|
||||||
|
|
||||||
|
const date = typeof raw?.date_gmt === "string" && raw.date_gmt ? raw.date_gmt : raw?.date;
|
||||||
|
const modified =
|
||||||
|
typeof raw?.modified_gmt === "string" && raw.modified_gmt ? raw.modified_gmt : raw?.modified;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(raw?.id ?? raw?.slug ?? ""),
|
||||||
|
kind: "post",
|
||||||
|
slug: String(raw?.slug ?? ""),
|
||||||
|
url: String(raw?.link ?? ""),
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
contentHtml: sanitizeHtml(String(contentHtml)),
|
||||||
|
featuredImageUrl: mapFeaturedImageUrl(raw),
|
||||||
|
publishedAt: new Date(date || 0).toISOString(),
|
||||||
|
updatedAt: new Date(modified || date || 0).toISOString(),
|
||||||
|
categoryIds: mapCategoryIds(raw),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWordpressPage(raw: any): WordpressPage {
|
||||||
|
const titleHtml = raw?.title?.rendered || "";
|
||||||
|
const excerptHtml = raw?.excerpt?.rendered || "";
|
||||||
|
const contentHtml = raw?.content?.rendered || "";
|
||||||
|
|
||||||
|
const title = decodeEntities(stripHtml(String(titleHtml)));
|
||||||
|
const excerpt = decodeEntities(stripHtml(String(excerptHtml)));
|
||||||
|
|
||||||
|
const date = typeof raw?.date_gmt === "string" && raw.date_gmt ? raw.date_gmt : raw?.date;
|
||||||
|
const modified =
|
||||||
|
typeof raw?.modified_gmt === "string" && raw.modified_gmt ? raw.modified_gmt : raw?.modified;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(raw?.id ?? raw?.slug ?? ""),
|
||||||
|
kind: "page",
|
||||||
|
slug: String(raw?.slug ?? ""),
|
||||||
|
url: String(raw?.link ?? ""),
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
contentHtml: sanitizeHtml(String(contentHtml)),
|
||||||
|
featuredImageUrl: mapFeaturedImageUrl(raw),
|
||||||
|
publishedAt: new Date(date || 0).toISOString(),
|
||||||
|
updatedAt: new Date(modified || date || 0).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWordpressCategory(raw: any): WordpressCategory {
|
||||||
|
return {
|
||||||
|
id: typeof raw?.id === "number" ? raw.id : Number(raw?.id ?? 0),
|
||||||
|
slug: String(raw?.slug ?? ""),
|
||||||
|
name: decodeEntities(stripHtml(String(raw?.name ?? ""))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWordpressContent(cfg: WordpressConfig): Promise<WordpressContent> {
|
||||||
|
const headers = getAuthHeaders(cfg);
|
||||||
|
|
||||||
|
// Default per_page max is often 100.
|
||||||
|
const posts = await fetchAllPages<any>(
|
||||||
|
(page) => wpUrl(cfg.baseUrl, `/wp-json/wp/v2/posts?per_page=100&page=${page}&_embed=1`),
|
||||||
|
headers,
|
||||||
|
);
|
||||||
|
const pages = await fetchAllPages<any>(
|
||||||
|
(page) => wpUrl(cfg.baseUrl, `/wp-json/wp/v2/pages?per_page=100&page=${page}&_embed=1`),
|
||||||
|
headers,
|
||||||
|
);
|
||||||
|
const categories = await fetchAllPages<any>(
|
||||||
|
(page) => wpUrl(cfg.baseUrl, `/wp-json/wp/v2/categories?per_page=100&page=${page}`),
|
||||||
|
headers,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts: posts.map(normalizeWordpressPost).filter((p) => p.slug && p.title),
|
||||||
|
pages: pages.map(normalizeWordpressPage).filter((p) => p.slug && p.title),
|
||||||
|
categories: categories.map(normalizeWordpressCategory).filter((c) => c.slug && c.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
54
site/src/pages/blog/category/[slug].astro
Normal file
54
site/src/pages/blog/category/[slug].astro
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
import BlogLayout from "../../../layouts/BlogLayout.astro";
|
||||||
|
import BlogPostCard from "../../../components/BlogPostCard.astro";
|
||||||
|
import { readContentCache } from "../../../lib/content/cache";
|
||||||
|
import {
|
||||||
|
wordpressCategories,
|
||||||
|
wordpressPostsByCategorySlug,
|
||||||
|
} from "../../../lib/content/selectors";
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const cache = await readContentCache();
|
||||||
|
return wordpressCategories(cache).map((c) => ({
|
||||||
|
params: { slug: c.slug },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = await readContentCache();
|
||||||
|
const categories = wordpressCategories(cache);
|
||||||
|
|
||||||
|
const slug = Astro.params.slug || "";
|
||||||
|
const activeCategory = categories.find((c) => c.slug === slug);
|
||||||
|
const posts = wordpressPostsByCategorySlug(cache, slug);
|
||||||
|
|
||||||
|
if (!activeCategory) {
|
||||||
|
// If the category doesn't exist, there will be no static route generated.
|
||||||
|
// But keep runtime behavior explicit.
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogLayout
|
||||||
|
title={`${activeCategory.name} | Blog | SanthoshJ`}
|
||||||
|
description={`Posts in category: ${activeCategory.name}`}
|
||||||
|
canonicalPath={`/blog/category/${activeCategory.slug}`}
|
||||||
|
categories={categories}
|
||||||
|
activeCategorySlug={activeCategory.slug}
|
||||||
|
>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>{activeCategory.name}</h2>
|
||||||
|
<span class="muted">{posts.length} post{posts.length === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{posts.length > 0 ? (
|
||||||
|
<div class="blog-grid">
|
||||||
|
{posts.map((p) => (
|
||||||
|
<BlogPostCard post={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="empty">No posts in this category yet.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</BlogLayout>
|
||||||
54
site/src/pages/blog/index.astro
Normal file
54
site/src/pages/blog/index.astro
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
import BlogLayout from "../../layouts/BlogLayout.astro";
|
||||||
|
import BlogPostCard from "../../components/BlogPostCard.astro";
|
||||||
|
import { readContentCache } from "../../lib/content/cache";
|
||||||
|
import { wordpressCategories, wordpressPages, wordpressPosts } from "../../lib/content/selectors";
|
||||||
|
|
||||||
|
const cache = await readContentCache();
|
||||||
|
const categories = wordpressCategories(cache);
|
||||||
|
const posts = wordpressPosts(cache);
|
||||||
|
const pages = wordpressPages(cache);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogLayout
|
||||||
|
title="Blog | SanthoshJ"
|
||||||
|
description="Latest posts from my WordPress blog."
|
||||||
|
canonicalPath="/blog"
|
||||||
|
categories={categories}
|
||||||
|
>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Latest posts</h2>
|
||||||
|
<span class="muted">{posts.length} post{posts.length === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{posts.length > 0 ? (
|
||||||
|
<div class="blog-grid">
|
||||||
|
{posts.map((p) => (
|
||||||
|
<BlogPostCard post={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="empty">No blog posts yet. Configure WordPress and run <code>npm run fetch-content</code>.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{pages.length > 0 ? (
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Pages</h2>
|
||||||
|
<a class="muted" href="/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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</BlogLayout>
|
||||||
|
|
||||||
49
site/src/pages/blog/page/[slug].astro
Normal file
49
site/src/pages/blog/page/[slug].astro
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
import BlogLayout from "../../../layouts/BlogLayout.astro";
|
||||||
|
import { readContentCache } from "../../../lib/content/cache";
|
||||||
|
import { wordpressCategories, wordpressPageBySlug, wordpressPages } from "../../../lib/content/selectors";
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const cache = await readContentCache();
|
||||||
|
return wordpressPages(cache).map((page) => ({
|
||||||
|
params: { slug: page.slug },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = await readContentCache();
|
||||||
|
const categories = wordpressCategories(cache);
|
||||||
|
|
||||||
|
const slug = Astro.params.slug || "";
|
||||||
|
const page = wordpressPageBySlug(cache, slug);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaDescription = (page.excerpt || "").slice(0, 160);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogLayout
|
||||||
|
title={`${page.title} | Blog | SanthoshJ`}
|
||||||
|
description={metaDescription || "Blog page"}
|
||||||
|
canonicalPath={`/blog/page/${page.slug}`}
|
||||||
|
categories={categories}
|
||||||
|
ogImageUrl={page.featuredImageUrl}
|
||||||
|
>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 style="margin: 0;">{page.title}</h2>
|
||||||
|
<a class="muted" href="/blog">Back →</a>
|
||||||
|
</div>
|
||||||
|
{page.featuredImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={page.featuredImageUrl}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
style="width: 100%; max-height: 420px; object-fit: cover; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.12);"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div class="prose" set:html={page.contentHtml} />
|
||||||
|
</section>
|
||||||
|
</BlogLayout>
|
||||||
|
|
||||||
37
site/src/pages/blog/pages.astro
Normal file
37
site/src/pages/blog/pages.astro
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
import BlogLayout from "../../layouts/BlogLayout.astro";
|
||||||
|
import { readContentCache } from "../../lib/content/cache";
|
||||||
|
import { wordpressCategories, wordpressPages } from "../../lib/content/selectors";
|
||||||
|
|
||||||
|
const cache = await readContentCache();
|
||||||
|
const categories = wordpressCategories(cache);
|
||||||
|
const pages = wordpressPages(cache);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogLayout
|
||||||
|
title="Blog Pages | SanthoshJ"
|
||||||
|
description="Pages from my WordPress site."
|
||||||
|
canonicalPath="/blog/pages"
|
||||||
|
categories={categories}
|
||||||
|
activeCategorySlug="__pages"
|
||||||
|
>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Pages</h2>
|
||||||
|
<span class="muted">{pages.length} page{pages.length === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pages.length > 0 ? (
|
||||||
|
<div class="empty">
|
||||||
|
{pages.map((p) => (
|
||||||
|
<div style="padding: 6px 0;">
|
||||||
|
<a href={`/blog/page/${p.slug}`}>{p.title}</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="empty">No pages yet.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</BlogLayout>
|
||||||
|
|
||||||
52
site/src/pages/blog/post/[slug].astro
Normal file
52
site/src/pages/blog/post/[slug].astro
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
import BlogLayout from "../../../layouts/BlogLayout.astro";
|
||||||
|
import { readContentCache } from "../../../lib/content/cache";
|
||||||
|
import { wordpressCategories, wordpressPostBySlug, wordpressPosts } from "../../../lib/content/selectors";
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const cache = await readContentCache();
|
||||||
|
return wordpressPosts(cache).map((post) => ({
|
||||||
|
params: { slug: post.slug },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = await readContentCache();
|
||||||
|
const categories = wordpressCategories(cache);
|
||||||
|
|
||||||
|
const slug = Astro.params.slug || "";
|
||||||
|
const post = wordpressPostBySlug(cache, slug);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaDescription = (post.excerpt || "").slice(0, 160);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogLayout
|
||||||
|
title={`${post.title} | Blog | SanthoshJ`}
|
||||||
|
description={metaDescription || "Blog post"}
|
||||||
|
canonicalPath={`/blog/post/${post.slug}`}
|
||||||
|
categories={categories}
|
||||||
|
ogImageUrl={post.featuredImageUrl}
|
||||||
|
>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 style="margin: 0;">{post.title}</h2>
|
||||||
|
<a class="muted" href="/blog">Back →</a>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-top: 0;">
|
||||||
|
{new Date(post.publishedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
{post.featuredImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={post.featuredImageUrl}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
style="width: 100%; max-height: 420px; object-fit: cover; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.12);"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div class="prose" set:html={post.contentHtml} />
|
||||||
|
</section>
|
||||||
|
</BlogLayout>
|
||||||
|
|
||||||
18
site/tests/blog-nav.test.ts
Normal file
18
site/tests/blog-nav.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("blog navigation", () => {
|
||||||
|
it("includes Blog link in header between Podcast and About", async () => {
|
||||||
|
const layoutPath = path.join(process.cwd(), "src", "layouts", "BaseLayout.astro");
|
||||||
|
const src = await readFile(layoutPath, "utf8");
|
||||||
|
|
||||||
|
// Order check: /podcast -> /blog -> /about
|
||||||
|
expect(src).toMatch(/href="\/podcast"[\s\S]*href="\/blog"[\s\S]*href="\/about"/);
|
||||||
|
|
||||||
|
// Tracking ID should exist
|
||||||
|
expect(src).toMatch(/data-umami-event-target_id="nav\.blog"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user