diff --git a/TASK.md b/TASK.md
index f6c555a..c80b194 100644
--- a/TASK.md
+++ b/TASK.md
@@ -8,10 +8,27 @@
## Current Status
-v1.33.0 ready. AI Chat Write Agent (Agent) integration complete with Anthropic Claude API support. Available on Write page and optionally in RightSidebar on posts/pages.
+v1.34.0 ready. Blog page featured layout with hero post, featured row, and regular posts grid. Uses `blogFeatured` frontmatter field to control featured posts display.
## Completed
+- [x] Blog page featured layout with hero post
+ - [x] `blogFeatured` frontmatter field for posts to mark as featured on blog page
+ - [x] `BlogHeroCard` component for the hero featured post (first blogFeatured post)
+ - [x] Featured row displays remaining blogFeatured posts in 2-column grid with excerpts
+ - [x] Regular posts display in 3-column grid without excerpts
+ - [x] `getBlogFeaturedPosts` query returns all published posts with `blogFeatured: true`
+ - [x] `PostList` component updated with `columns` prop (2 or 3) and `showExcerpts` prop
+ - [x] Schema updated with `blogFeatured` field and `by_blogFeatured` index
+ - [x] sync-posts.ts updated to parse `blogFeatured` frontmatter
+ - [x] Hero card displays landscape image, tags, date, title, excerpt, author info, and read more link
+ - [x] Featured row shows excerpts for blogFeatured posts
+ - [x] Regular posts hide excerpts for cleaner grid layout
+ - [x] Responsive design: hero stacks on mobile, grids adjust columns at breakpoints
+ - [x] CSS styles for `.blog-hero-section`, `.blog-hero-card`, `.blog-featured-row`, `.post-cards-2col`
+ - [x] Card images use 16:10 landscape aspect ratio matching Giga.ai style
+ - [x] Footer support on blog page via `siteConfig.footer.showOnBlogPage`
+
- [x] AI Chat Write Agent (Agent) integration
- [x] AIChatView component created with Anthropic Claude API integration
- [x] Write page AI Agent mode toggle (replaces textarea when active)
diff --git a/changelog.md b/changelog.md
index f4ad98c..c48bf8a 100644
--- a/changelog.md
+++ b/changelog.md
@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+## [1.34.0] - 2025-12-26
+
+### Added
+
+- Blog page featured layout with hero post
+ - `blogFeatured` frontmatter field for posts to mark as featured on blog page
+ - First `blogFeatured` post displays as hero card with landscape image, tags, date, title, excerpt, author info, and read more link
+ - Remaining `blogFeatured` posts display in 2-column featured row with excerpts
+ - Regular (non-featured) posts display in 3-column grid without excerpts
+ - New `BlogHeroCard` component (`src/components/BlogHeroCard.tsx`) for hero display
+ - New `getBlogFeaturedPosts` query returns all published posts with `blogFeatured: true` sorted by date
+ - `PostList` component updated with `columns` prop (2 or 3) and `showExcerpts` prop
+ - Card images use 16:10 landscape aspect ratio
+ - Footer support on blog page via `siteConfig.footer.showOnBlogPage`
+
+### Changed
+
+- `convex/schema.ts`: Added `blogFeatured` field to posts table with `by_blogFeatured` index
+- `convex/posts.ts`: Added `getBlogFeaturedPosts` query, updated sync mutations to handle `blogFeatured` field
+- `scripts/sync-posts.ts`: Updated to parse `blogFeatured` from post frontmatter
+- `src/pages/Blog.tsx`: Refactored to display hero, featured row, and regular posts sections
+- `src/components/PostList.tsx`: Added `columns` and `showExcerpts` props for layout control
+- `src/styles/global.css`: Added styles for `.blog-hero-section`, `.blog-hero-card`, `.blog-featured-row`, `.post-cards-2col`
+
+### Technical
+
+- Hero card responsive design: stacks content on mobile, side-by-side on desktop
+- Featured row uses 2-column grid with excerpts visible
+- Regular posts grid uses 3-column layout without excerpts for cleaner appearance
+- Responsive breakpoints: 2 columns at 768px, 1 column at 480px
+- Layout class names updated: `blog-page-cards` and `blog-page-list` for view modes
+
+## [1.33.1] - 2025-12-26
+
+### Changed
+
+- Article centering in sidebar layouts
+ - Article content now centers in the middle column when sidebars are present
+ - Left sidebar stays flush left, right sidebar stays flush right
+ - Article uses `margin-left: auto; margin-right: auto` within its `1fr` grid column
+ - Works with both two-column (left sidebar only) and three-column (both sidebars) layouts
+ - Consistent `max-width: 800px` for article content across all sidebar configurations
+
+### Technical
+
+- Updated `.post-article-with-sidebar` in `src/styles/global.css` with auto margins for centering
+- Added `padding-right: 48px` to match left padding for balanced spacing
+
## [1.33.0] - 2025-12-26
### Added
diff --git a/content/blog/happy-holidays-2025.md b/content/blog/happy-holidays-2025.md
index 2bbb50e..1f8e98b 100644
--- a/content/blog/happy-holidays-2025.md
+++ b/content/blog/happy-holidays-2025.md
@@ -8,6 +8,8 @@ tags: ["updates", "community", "ai"]
readTime: "2 min read"
featured: true
featuredOrder: 0
+blogFeatured: true
+aiChat: true
image: /images/1225-changelog.png
excerpt: "Thank you for the stars, forks, and feedback. More AI-first publishing features are coming."
authorName: "Wayne Sutton"
diff --git a/content/blog/how-to-publish.md b/content/blog/how-to-publish.md
index 681f871..6bedd7a 100644
--- a/content/blog/how-to-publish.md
+++ b/content/blog/how-to-publish.md
@@ -9,8 +9,7 @@ readTime: "3 min read"
featured: false
featuredOrder: 3
authorName: "Markdown"
-layout: "sidebar"
-rightSidebar: true
+blogFeatured: true
authorImage: "/images/authors/markdown.png"
image: "/images/matthew-smith-Rfflri94rs8-unsplash.jpg"
excerpt: "Quick guide to writing and publishing markdown posts with npm run sync."
diff --git a/content/blog/using-images-in-posts.md b/content/blog/using-images-in-posts.md
index e2bf344..5ee4713 100644
--- a/content/blog/using-images-in-posts.md
+++ b/content/blog/using-images-in-posts.md
@@ -8,6 +8,7 @@ featured: false
featuredOrder: 4
tags: ["images", "tutorial", "markdown", "open-graph"]
readTime: "4 min read"
+blogFeatured: true
authorName: "Markdown"
authorImage: "/images/authors/markdown.png"
image: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=630&fit=crop"
@@ -52,6 +53,10 @@ featuredOrder: 1
## Inline Images
+You can add images using markdown syntax or HTML. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content.
+
+### Markdown Syntax
+
Add images anywhere in your markdown content using standard syntax:
```markdown
@@ -64,6 +69,29 @@ Here's an example image from Unsplash:
The alt text appears as a caption below the image.
+### HTML Syntax
+
+You can also use HTML `` tags directly in your markdown:
+
+```html
+
+```
+
+Or with additional attributes:
+
+```html
+
+```
+
+**HTML images:** HTML `` tags are sanitized for security using `rehypeSanitize`. Allowed attributes include `src`, `alt`, `width`, `height`, `loading`, and `class`. The alt text still appears as a caption below HTML images, matching the markdown behavior.
+
+**Combining markdown and HTML:** You can mix markdown and HTML in the same post. Both syntaxes render images with the same styling and caption behavior.
+
## Image Sources
You can use images from:
@@ -121,3 +149,4 @@ These sites offer free, high-quality images:
- [Pexels](https://pexels.com) - Photos and videos
- [unDraw](https://undraw.co) - Illustrations
- [Heroicons](https://heroicons.com) - Icons
+- [Phosphor Icons](https://phosphoricons.com/) - Icons
diff --git a/content/pages/docs.md b/content/pages/docs.md
index 8320faf..91700bd 100644
--- a/content/pages/docs.md
+++ b/content/pages/docs.md
@@ -102,22 +102,22 @@ image: "/images/og-image.png"
Content here...
```
-| Field | Required | Description |
-| --------------- | -------- | ------------------------------------------------- |
-| `title` | Yes | Post title |
-| `description` | Yes | SEO description |
-| `date` | Yes | YYYY-MM-DD format |
-| `slug` | Yes | URL path (unique) |
-| `published` | Yes | `true` to show |
-| `tags` | Yes | Array of strings |
-| `readTime` | No | Display time estimate |
-| `image` | No | OG image and featured card thumbnail |
-| `excerpt` | No | Short text for card view |
-| `featured` | No | `true` to show in featured section |
-| `featuredOrder` | No | Order in featured (lower = first) |
-| `authorName` | No | Author display name shown next to date |
-| `authorImage` | No | Round author avatar image URL |
-| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
+| Field | Required | Description |
+| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `title` | Yes | Post title |
+| `description` | Yes | SEO description |
+| `date` | Yes | YYYY-MM-DD format |
+| `slug` | Yes | URL path (unique) |
+| `published` | Yes | `true` to show |
+| `tags` | Yes | Array of strings |
+| `readTime` | No | Display time estimate |
+| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax |
+| `excerpt` | No | Short text for card view |
+| `featured` | No | `true` to show in featured section |
+| `featuredOrder` | No | Order in featured (lower = first) |
+| `authorName` | No | Author display name shown next to date |
+| `authorImage` | No | Round author avatar image URL |
+| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
### Static pages
@@ -733,6 +733,8 @@ Mobile sizes defined in `@media (max-width: 768px)` block.
The `npm run sync` command only syncs markdown text content. Images are deployed when Netlify builds your site. Use `npm run sync:discovery` to update discovery files (AGENTS.md, llms.txt) when site configuration changes.
+**Adding images to posts:** You can add images using markdown syntax `` or HTML `` tags. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content. See [Using Images in Blog Posts](/using-images-in-posts) for complete examples and best practices.
+
**Logo options:**
- **Homepage logo:** Configured via `logo` in `siteConfig.ts`. Set to `null` to hide.
diff --git a/convex/aiChatActions.ts b/convex/aiChatActions.ts
index 67b63f4..56ef9ab 100644
--- a/convex/aiChatActions.ts
+++ b/convex/aiChatActions.ts
@@ -105,10 +105,25 @@ export const generateResponse = action({
},
returns: v.string(),
handler: async (ctx, args) => {
- // Get API key
+ // Get API key - return friendly message if not configured
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
- throw new Error("API key is not set");
+ const notConfiguredMessage =
+ "**AI chat is not configured.**\n\n" +
+ "To enable AI responses, add your `ANTHROPIC_API_KEY` to the Convex environment variables.\n\n" +
+ "**Setup steps:**\n" +
+ "1. Get an API key from [Anthropic Console](https://console.anthropic.com/)\n" +
+ "2. Add it to Convex: `npx convex env set ANTHROPIC_API_KEY your-key-here`\n" +
+ "3. For production, set it in the [Convex Dashboard](https://dashboard.convex.dev/)\n\n" +
+ "See the [Convex environment variables docs](https://docs.convex.dev/production/environment-variables) for more details.";
+
+ // Save the message to chat history so it appears in the conversation
+ await ctx.runMutation(internal.aiChats.addAssistantMessage, {
+ chatId: args.chatId,
+ content: notConfiguredMessage,
+ });
+
+ return notConfiguredMessage;
}
// Get chat history
diff --git a/convex/posts.ts b/convex/posts.ts
index 3dce6a2..2488bd4 100644
--- a/convex/posts.ts
+++ b/convex/posts.ts
@@ -25,6 +25,7 @@ export const getAllPosts = query({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
+ blogFeatured: v.optional(v.boolean()),
}),
),
handler: async (ctx) => {
@@ -58,6 +59,53 @@ export const getAllPosts = query({
layout: post.layout,
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
+ blogFeatured: post.blogFeatured,
+ }));
+ },
+});
+
+// Get all blog featured posts for the /blog page (hero + featured row)
+// Returns posts with blogFeatured: true, sorted by date descending
+export const getBlogFeaturedPosts = query({
+ args: {},
+ returns: v.array(
+ v.object({
+ _id: v.id("posts"),
+ slug: v.string(),
+ title: v.string(),
+ description: v.string(),
+ date: v.string(),
+ tags: v.array(v.string()),
+ readTime: v.optional(v.string()),
+ image: v.optional(v.string()),
+ excerpt: v.optional(v.string()),
+ authorName: v.optional(v.string()),
+ authorImage: v.optional(v.string()),
+ }),
+ ),
+ handler: async (ctx) => {
+ const posts = await ctx.db
+ .query("posts")
+ .withIndex("by_blogFeatured", (q) => q.eq("blogFeatured", true))
+ .collect();
+
+ // Filter to only published posts and sort by date descending
+ const publishedFeatured = posts
+ .filter((p) => p.published)
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
+
+ return publishedFeatured.map((post) => ({
+ _id: post._id,
+ slug: post.slug,
+ title: post.title,
+ description: post.description,
+ date: post.date,
+ tags: post.tags,
+ readTime: post.readTime,
+ image: post.image,
+ excerpt: post.excerpt,
+ authorName: post.authorName,
+ authorImage: post.authorImage,
}));
},
});
@@ -194,6 +242,7 @@ export const syncPosts = internalMutation({
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
aiChat: v.optional(v.boolean()),
+ blogFeatured: v.optional(v.boolean()),
}),
),
},
@@ -239,6 +288,7 @@ export const syncPosts = internalMutation({
showFooter: post.showFooter,
footer: post.footer,
aiChat: post.aiChat,
+ blogFeatured: post.blogFeatured,
lastSyncedAt: now,
});
updated++;
@@ -288,6 +338,7 @@ export const syncPostsPublic = mutation({
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
aiChat: v.optional(v.boolean()),
+ blogFeatured: v.optional(v.boolean()),
}),
),
},
@@ -333,6 +384,7 @@ export const syncPostsPublic = mutation({
showFooter: post.showFooter,
footer: post.footer,
aiChat: post.aiChat,
+ blogFeatured: post.blogFeatured,
lastSyncedAt: now,
});
updated++;
diff --git a/convex/schema.ts b/convex/schema.ts
index 84ad448..96bd1de 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -23,12 +23,14 @@ export default defineSchema({
showFooter: v.optional(v.boolean()), // Show footer on this post (overrides siteConfig default)
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
+ blogFeatured: v.optional(v.boolean()), // Show as hero featured post on /blog page
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_date", ["date"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
+ .index("by_blogFeatured", ["blogFeatured"])
.searchIndex("search_content", {
searchField: "content",
filterFields: ["published"],
diff --git a/files.md b/files.md
index 7062ba5..8040226 100644
--- a/files.md
+++ b/files.md
@@ -40,7 +40,7 @@ A brief description of each file in the codebase.
| File | Description |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Home.tsx` | Landing page with featured content and optional post list. Supports configurable post limit (homePostsLimit) and optional "read more" link (homePostsReadMore) via siteConfig.postsDisplay |
-| `Blog.tsx` | Dedicated blog page with post list or card grid view (configurable via siteConfig.blogPage, supports view toggle). Includes back button in navigation |
+| `Blog.tsx` | Dedicated blog page with featured layout: hero post (first blogFeatured), featured row (remaining blogFeatured in 2 columns with excerpts), and regular posts (3 columns without excerpts). Supports list/card view toggle. Includes back button in navigation |
| `Post.tsx` | Individual blog post or page view with optional left sidebar (TOC) and right sidebar (CopyPageDropdown). Includes back button (hidden when used as homepage), tag links, and related posts section in footer for blog posts. Supports 3-column layout at 1135px+. Can be used as custom homepage via siteConfig.homepage (update SITE_URL/SITE_NAME when forking) |
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars |
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
@@ -52,7 +52,8 @@ A brief description of each file in the codebase.
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Layout.tsx` | Page wrapper with logo in header (top-left), search button, theme toggle, mobile menu (left-aligned on mobile), and scroll-to-top. Combines Blog link, hardcoded nav items, and markdown pages for navigation. Logo reads from siteConfig.innerPageLogo |
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
-| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes) |
+| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes, columns prop for 2/3 column grids, showExcerpts prop to control excerpt visibility) |
+| `BlogHeroCard.tsx` | Hero card component for the first blogFeatured post on blog page. Displays landscape image, tags, date, title, excerpt, author info, and read more link |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting, collapsible sections (details/summary), and text wrapping for plain text code blocks |
| `CopyPageDropdown.tsx` | Share dropdown with Copy page (markdown to clipboard), View as Markdown (opens raw .md file), Download as SKILL.md (Anthropic Agent Skills format), and Open in AI links (ChatGPT, Claude, Perplexity) using GitHub raw URLs with universal prompt |
| `Footer.tsx` | Footer component that renders markdown content from frontmatter footer field or siteConfig.defaultContent. Can be enabled/disabled globally and per-page via frontmatter showFooter field. Renders inside article at bottom for posts/pages, and in current position on homepage. Supports images with size control via HTML attributes (width, height, style, class) |
@@ -143,6 +144,7 @@ Markdown files with frontmatter for blog posts. Each file becomes a blog post.
| `excerpt` | Short excerpt for card view (optional) |
| `featured` | Show in featured section (optional) |
| `featuredOrder` | Order in featured section (optional) |
+| `blogFeatured` | Show as featured on blog page (optional, first becomes hero, rest in 2-col row) |
| `authorName` | Author display name (optional) |
| `authorImage` | Round author avatar image URL (optional) |
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
diff --git a/public/raw/about.md b/public/raw/about.md
index ab3b2d1..81c9bbe 100644
--- a/public/raw/about.md
+++ b/public/raw/about.md
@@ -2,7 +2,7 @@
---
Type: page
-Date: 2025-12-26
+Date: 2025-12-27
---
An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs.. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.
diff --git a/public/raw/changelog.md b/public/raw/changelog.md
index 0b505b6..f5a6253 100644
--- a/public/raw/changelog.md
+++ b/public/raw/changelog.md
@@ -2,7 +2,7 @@
---
Type: page
-Date: 2025-12-26
+Date: 2025-12-27
---
All notable changes to this project.
diff --git a/public/raw/contact.md b/public/raw/contact.md
index 05e6544..4ed309b 100644
--- a/public/raw/contact.md
+++ b/public/raw/contact.md
@@ -2,7 +2,7 @@
---
Type: page
-Date: 2025-12-26
+Date: 2025-12-27
---
You found the contact page. Nice
diff --git a/public/raw/docs.md b/public/raw/docs.md
index 8732b02..fb78d54 100644
--- a/public/raw/docs.md
+++ b/public/raw/docs.md
@@ -2,7 +2,7 @@
---
Type: page
-Date: 2025-12-26
+Date: 2025-12-27
---
## Getting Started
@@ -98,22 +98,22 @@ image: "/images/og-image.png"
Content here...
```
-| Field | Required | Description |
-| --------------- | -------- | ------------------------------------------------- |
-| `title` | Yes | Post title |
-| `description` | Yes | SEO description |
-| `date` | Yes | YYYY-MM-DD format |
-| `slug` | Yes | URL path (unique) |
-| `published` | Yes | `true` to show |
-| `tags` | Yes | Array of strings |
-| `readTime` | No | Display time estimate |
-| `image` | No | OG image and featured card thumbnail |
-| `excerpt` | No | Short text for card view |
-| `featured` | No | `true` to show in featured section |
-| `featuredOrder` | No | Order in featured (lower = first) |
-| `authorName` | No | Author display name shown next to date |
-| `authorImage` | No | Round author avatar image URL |
-| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
+| Field | Required | Description |
+| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `title` | Yes | Post title |
+| `description` | Yes | SEO description |
+| `date` | Yes | YYYY-MM-DD format |
+| `slug` | Yes | URL path (unique) |
+| `published` | Yes | `true` to show |
+| `tags` | Yes | Array of strings |
+| `readTime` | No | Display time estimate |
+| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax |
+| `excerpt` | No | Short text for card view |
+| `featured` | No | `true` to show in featured section |
+| `featuredOrder` | No | Order in featured (lower = first) |
+| `authorName` | No | Author display name shown next to date |
+| `authorImage` | No | Round author avatar image URL |
+| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
### Static pages
@@ -729,6 +729,8 @@ Mobile sizes defined in `@media (max-width: 768px)` block.
The `npm run sync` command only syncs markdown text content. Images are deployed when Netlify builds your site. Use `npm run sync:discovery` to update discovery files (AGENTS.md, llms.txt) when site configuration changes.
+**Adding images to posts:** You can add images using markdown syntax `` or HTML `` tags. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content. See [Using Images in Blog Posts](/using-images-in-posts) for complete examples and best practices.
+
**Logo options:**
- **Homepage logo:** Configured via `logo` in `siteConfig.ts`. Set to `null` to hide.
diff --git a/public/raw/projects.md b/public/raw/projects.md
index caa8f71..d187b31 100644
--- a/public/raw/projects.md
+++ b/public/raw/projects.md
@@ -2,7 +2,7 @@
---
Type: page
-Date: 2025-12-26
+Date: 2025-12-27
---
This markdown framework is open source and built to be extended. Here is what ships out of the box.
diff --git a/public/raw/using-images-in-posts.md b/public/raw/using-images-in-posts.md
index 1254fc9..6401447 100644
--- a/public/raw/using-images-in-posts.md
+++ b/public/raw/using-images-in-posts.md
@@ -48,6 +48,10 @@ featuredOrder: 1
## Inline Images
+You can add images using markdown syntax or HTML. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content.
+
+### Markdown Syntax
+
Add images anywhere in your markdown content using standard syntax:
```markdown
@@ -60,6 +64,29 @@ Here's an example image from Unsplash:
The alt text appears as a caption below the image.
+### HTML Syntax
+
+You can also use HTML `` tags directly in your markdown:
+
+```html
+
+```
+
+Or with additional attributes:
+
+```html
+
+```
+
+**HTML images:** HTML `` tags are sanitized for security using `rehypeSanitize`. Allowed attributes include `src`, `alt`, `width`, `height`, `loading`, and `class`. The alt text still appears as a caption below HTML images, matching the markdown behavior.
+
+**Combining markdown and HTML:** You can mix markdown and HTML in the same post. Both syntaxes render images with the same styling and caption behavior.
+
## Image Sources
You can use images from:
@@ -116,4 +143,5 @@ These sites offer free, high-quality images:
- [Unsplash](https://unsplash.com) - Photos
- [Pexels](https://pexels.com) - Photos and videos
- [unDraw](https://undraw.co) - Illustrations
-- [Heroicons](https://heroicons.com) - Icons
\ No newline at end of file
+- [Heroicons](https://heroicons.com) - Icons
+- [Phosphor Icons](https://phosphoricons.com/) - Icons
\ No newline at end of file
diff --git a/scripts/sync-posts.ts b/scripts/sync-posts.ts
index 64be1b1..9721d4f 100644
--- a/scripts/sync-posts.ts
+++ b/scripts/sync-posts.ts
@@ -39,6 +39,7 @@ interface PostFrontmatter {
layout?: string; // Layout type: "sidebar" for docs-style layout
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
+ blogFeatured?: boolean; // Show as hero featured post on /blog page
}
interface ParsedPost {
@@ -61,6 +62,7 @@ interface ParsedPost {
showFooter?: boolean; // Show footer on this post (overrides siteConfig default)
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
+ blogFeatured?: boolean; // Show as hero featured post on /blog page
}
// Page frontmatter (for static pages like About, Projects, Contact)
@@ -143,6 +145,7 @@ function parseMarkdownFile(filePath: string): ParsedPost | null {
showFooter: frontmatter.showFooter, // Show footer on this post
footer: frontmatter.footer, // Footer markdown content
aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar
+ blogFeatured: frontmatter.blogFeatured, // Show as hero featured post on /blog page
};
} catch (error) {
console.error(`Error parsing ${filePath}:`, error);
diff --git a/src/components/BlogHeroCard.tsx b/src/components/BlogHeroCard.tsx
new file mode 100644
index 0000000..1eb23c4
--- /dev/null
+++ b/src/components/BlogHeroCard.tsx
@@ -0,0 +1,89 @@
+import { Link } from "react-router-dom";
+import { format, parseISO } from "date-fns";
+
+interface BlogHeroCardProps {
+ slug: string;
+ title: string;
+ description: string;
+ date: string;
+ tags: string[];
+ readTime?: string;
+ image?: string;
+ excerpt?: string;
+ authorName?: string;
+ authorImage?: string;
+}
+
+// Hero card component for featured blog post on /blog page
+// Displays as a large card with image on left, content on right (like Giga.ai/news)
+export default function BlogHeroCard({
+ slug,
+ title,
+ description,
+ date,
+ tags,
+ readTime,
+ image,
+ excerpt,
+ authorName,
+ authorImage,
+}: BlogHeroCardProps) {
+ return (
+
+ {/* Hero image on the left */}
+ {image && (
+
+
+ );
+}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 2ab91ea..bb6a34f 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -221,10 +221,12 @@ export default function Layout({ children }: LayoutProps) {
- {/* Use wider layout for stats page, normal layout for other pages */}
+ {/* Use wider layout for stats and blog pages, normal layout for other pages */}
{children}
diff --git a/src/components/PostList.tsx b/src/components/PostList.tsx
index 83eb939..d7e9b43 100644
--- a/src/components/PostList.tsx
+++ b/src/components/PostList.tsx
@@ -16,6 +16,8 @@ interface Post {
interface PostListProps {
posts: Post[];
viewMode?: "list" | "cards";
+ columns?: 2 | 3; // Number of columns for card view (default: 3)
+ showExcerpts?: boolean; // Show excerpts in card view (default: true)
}
// Group posts by year
@@ -33,7 +35,12 @@ function groupByYear(posts: Post[]): Record {
);
}
-export default function PostList({ posts, viewMode = "list" }: PostListProps) {
+export default function PostList({
+ posts,
+ viewMode = "list",
+ columns = 3,
+ showExcerpts = true,
+}: PostListProps) {
// Sort posts by date descending
const sortedPosts = [...posts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -41,8 +48,11 @@ export default function PostList({ posts, viewMode = "list" }: PostListProps) {
// Card view: render all posts in a grid
if (viewMode === "cards") {
+ // Apply column class for 2 or 3 columns
+ const cardGridClass =
+ columns === 2 ? "post-cards post-cards-2col" : "post-cards";
return (
-
+
{sortedPosts.map((post) => (
{/* Thumbnail image displayed as square using object-fit: cover */}
@@ -58,7 +68,8 @@ export default function PostList({ posts, viewMode = "list" }: PostListProps) {
)}
{post.title}
- {(post.excerpt || post.description) && (
+ {/* Only show excerpt if showExcerpts is true */}
+ {showExcerpts && (post.excerpt || post.description) && (
{post.excerpt || post.description}
diff --git a/src/config/siteConfig.ts b/src/config/siteConfig.ts
index 39d4d1d..5672074 100644
--- a/src/config/siteConfig.ts
+++ b/src/config/siteConfig.ts
@@ -95,6 +95,7 @@ export interface FooterConfig {
showOnHomepage: boolean; // Show footer on homepage
showOnPosts: boolean; // Default: show footer on blog posts
showOnPages: boolean; // Default: show footer on static pages
+ showOnBlogPage: boolean; // Show footer on /blog page
defaultContent?: string; // Default markdown content if no frontmatter footer field provided
}
@@ -266,7 +267,7 @@ export const siteConfig: SiteConfig = {
title: "Blog", // Page title
description: "All posts from the blog, sorted by date.", // Optional description
order: 2, // Nav order (lower = first, e.g., 0 = first, 5 = after pages with order 0-4)
- viewMode: "list", // Default view mode: "list" or "cards"
+ viewMode: "cards", // Default view mode: "list" or "cards"
showViewToggle: true, // Show toggle button to switch between list and card views
},
@@ -337,6 +338,7 @@ export const siteConfig: SiteConfig = {
showOnHomepage: true, // Show footer on homepage
showOnPosts: true, // Default: show footer on blog posts (override with frontmatter)
showOnPages: true, // Default: show footer on static pages (override with frontmatter)
+ showOnBlogPage: true, // Show footer on /blog page
// Default footer markdown (used when frontmatter footer field is not provided)
defaultContent: `Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). Read the [project on GitHub](https://github.com/waynesutton/markdown-site) to fork and deploy your own. View [real-time site stats](/stats).
diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx
index dcd378e..e3d76fb 100644
--- a/src/pages/Blog.tsx
+++ b/src/pages/Blog.tsx
@@ -3,6 +3,8 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import PostList from "../components/PostList";
+import BlogHeroCard from "../components/BlogHeroCard";
+import Footer from "../components/Footer";
import siteConfig from "../config/siteConfig";
import { ArrowLeft } from "lucide-react";
@@ -10,14 +12,20 @@ import { ArrowLeft } from "lucide-react";
const BLOG_VIEW_MODE_KEY = "blog-view-mode";
// Blog page component
-// Displays all published posts in a year-grouped list or card grid
+// Displays all published posts with featured blog posts layout:
+// 1. Hero: first blogFeatured post (large card)
+// 2. Featured row: remaining blogFeatured posts (2 columns)
+// 3. Regular posts: non-featured posts (3 columns)
// Controlled by siteConfig.blogPage and siteConfig.postsDisplay settings
export default function Blog() {
const navigate = useNavigate();
- // Fetch published posts from Convex
+ // Fetch all published posts from Convex
const posts = useQuery(api.posts.getAllPosts);
+ // Fetch all blog featured posts for hero + featured row
+ const blogFeaturedPosts = useQuery(api.posts.getBlogFeaturedPosts);
+
// State for view mode toggle (list or cards)
const [viewMode, setViewMode] = useState<"list" | "cards">(
siteConfig.blogPage.viewMode,
@@ -41,8 +49,33 @@ export default function Blog() {
// Check if posts should be shown on blog page
const showPosts = siteConfig.postsDisplay.showOnBlogPage;
+ // Check if footer should be shown on blog page
+ const showFooter =
+ siteConfig.footer.enabled && siteConfig.footer.showOnBlogPage;
+
+ // Split featured posts: first one is hero, rest go to featured row
+ const heroPost = blogFeaturedPosts && blogFeaturedPosts.length > 0 ? blogFeaturedPosts[0] : null;
+ const featuredRowPosts = blogFeaturedPosts && blogFeaturedPosts.length > 1 ? blogFeaturedPosts.slice(1) : [];
+
+ // Get slugs of all featured posts for filtering
+ const featuredSlugs = new Set(blogFeaturedPosts?.map((p) => p.slug) || []);
+
+ // Filter out all featured posts from regular posts list
+ const regularPosts = posts?.filter((post) => !featuredSlugs.has(post.slug));
+
+ // Determine if we have featured content to show
+ const hasFeaturedContent = heroPost !== null;
+
+ // Build CSS class for the blog page
+ const blogPageClass = [
+ "blog-page",
+ viewMode === "cards" ? "blog-page-cards" : "blog-page-list",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
return (
-
+
{/* Navigation with back button */}
);
}
diff --git a/src/pages/Post.tsx b/src/pages/Post.tsx
index de87aed..8137087 100644
--- a/src/pages/Post.tsx
+++ b/src/pages/Post.tsx
@@ -206,6 +206,8 @@ export default function Post({
// Check if right sidebar is enabled (only when explicitly set in frontmatter)
const hasRightSidebar = siteConfig.rightSidebar.enabled && page.rightSidebar === true;
const hasAnySidebar = hasLeftSidebar || hasRightSidebar;
+ // Track if only right sidebar is enabled (for centering article)
+ const hasOnlyRightSidebar = hasRightSidebar && !hasLeftSidebar;
return (
@@ -229,7 +231,7 @@ export default function Post({
)}
-