feat: blog view toggle, page sidebar layout, and collapsible markdown

- Blog page: list/card view toggle with localStorage persistence
- Pages: sidebar layout with auto-generated TOC (layout: "sidebar")
- Markdown: collapsible sections via <details>/<summary> tags
- Add rehype-raw and rehype-sanitize for HTML in markdown
This commit is contained in:
Wayne Sutton
2025-12-23 00:21:57 -08:00
parent d47062bb32
commit edb7fc6723
31 changed files with 1912 additions and 178 deletions

20
TASK.md
View File

@@ -2,12 +2,30 @@
## To Do
- [ ] fix netlify markdown bug
- [ ] add MIT Licensed. Do whatevs.
- [ ] add mcp
- [ ] https://www.npmjs.com/package/remark-rehype
- [ ] https://github.com/remarkjs/remark-rehype
- [ ] https://github.com/remarkjs/remark-rehype
- [ ] https://remark.js.org/
- [ ] https://unifiedjs.com/explore/package/rehype-raw/
- [ ] - add markdown html https://gist.github.com/pierrejoubert73/902cc94d79424356a8d20be2b382e1ab
- [ ]
## Current Status
v1.20.3 deployed. SEO/AEO/GEO improvements for AI crawlers and search engines.
v1.21.0 deployed. Blog page view mode toggle with list and card views.
## Completed
- [x] Blog page view mode toggle (list and card views)
- [x] Post cards component with thumbnails, titles, excerpts, and metadata
- [x] View preference saved to localStorage
- [x] Default view mode configurable in siteConfig.blogPage.viewMode
- [x] Toggle visibility controlled by siteConfig.blogPage.showViewToggle
- [x] Responsive grid: 3 columns (desktop), 2 columns (tablet), 1 column (mobile)
- [x] Theme-aware styling for all four themes
- [x] Raw markdown files now accessible to AI crawlers (ChatGPT, Perplexity)
- [x] Added /raw/ path bypass in botMeta edge function
- [x] Sitemap now includes static pages (about, docs, contact, etc.)

View File

@@ -4,6 +4,86 @@ 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.23.0] - 2025-12-23
### Added
- Collapsible sections in markdown using HTML `<details>` and `<summary>` tags
- Create expandable/collapsible content in blog posts and pages
- Use `<details open>` attribute for sections that start expanded
- Supports nested collapsible sections
- Theme-aware styling for all four themes (dark, light, tan, cloud)
- Works with all markdown content inside: lists, code blocks, bold, italic, etc.
### Technical
- Added `rehype-raw` package to allow raw HTML pass-through in react-markdown
- Added `rehype-sanitize` package to strip dangerous tags while allowing safe ones
- Custom sanitize schema allows `details`, `summary` tags and the `open` attribute
- Updated `src/components/BlogPost.tsx` with rehype plugins
- CSS styles for collapsible sections in `src/styles/global.css`
### Documentation
- Updated `markdown-with-code-examples.md` with collapsible section examples
- Updated `docs.md` with collapsible sections documentation
- Updated `files.md` with BlogPost.tsx description change
## [1.22.0] - 2025-12-21
### Added
- Sidebar layout for pages with table of contents
- Add `layout: "sidebar"` to page frontmatter to enable docs-style layout
- Left sidebar displays table of contents extracted from H1, H2, H3 headings
- Two-column grid layout: 220px sidebar + flexible content area
- Sidebar only appears if headings exist in the page content
- Active heading highlighting on scroll
- Smooth scroll navigation to sections
- CopyPageDropdown remains in top navigation for sidebar pages
- Mobile responsive: stacks to single column below 1024px
### Technical
- New utility: `src/utils/extractHeadings.ts` for parsing markdown headings
- New component: `src/components/PageSidebar.tsx` for TOC navigation
- Updated `convex/schema.ts`: Added optional `layout` field to pages table
- Updated `scripts/sync-posts.ts`: Parses `layout` field from page frontmatter
- Updated `convex/pages.ts`: Includes `layout` field in queries and mutations
- Updated `src/pages/Post.tsx`: Conditionally renders sidebar layout
- CSS grid layout with sticky sidebar positioning
- Full-width container breaks out of main-content constraints
## [1.21.0] - 2025-12-21
### Added
- Blog page view mode toggle (list and card views)
- Toggle button in blog header to switch between list and card views
- Card view displays posts in a 3-column grid with thumbnails, titles, excerpts, and metadata
- List view shows year-grouped posts (existing behavior)
- View preference saved to localStorage
- Default view mode configurable via `siteConfig.blogPage.viewMode`
- Toggle visibility controlled by `siteConfig.blogPage.showViewToggle`
- Post cards component
- Displays post thumbnails, titles, excerpts, read time, and dates
- Responsive grid: 3 columns (desktop), 2 columns (tablet), 1 column (mobile)
- Theme-aware styling for all four themes (dark, light, tan, cloud)
- Square thumbnails with hover zoom effect
- Cards without images display with adjusted padding
### Changed
- Updated `PostList` component to support both list and card view modes
- Updated `Blog.tsx` to include view toggle button and state management
- Updated `siteConfig.ts` with `blogPage.viewMode` and `blogPage.showViewToggle` options
### Technical
- New CSS classes: `.post-cards`, `.post-card`, `.post-card-image-wrapper`, `.post-card-content`, `.post-card-meta`
- Reuses featured card styling patterns for consistency
- Mobile responsive with adjusted grid columns and image aspect ratios
## [1.20.3] - 2025-12-21
### Fixed

View File

@@ -382,6 +382,124 @@ Control column alignment with colons:
| :--- | :----: | ----: |
| L | C | R |
## Collapsible sections
Use HTML `<details>` and `<summary>` tags to create expandable/collapsible content:
### Basic toggle
```html
<details>
<summary>Click to expand</summary>
Hidden content goes here. You can include:
- Lists
- **Bold** and _italic_ text
- Code blocks
- Any markdown content
</details>
```
<details>
<summary>Click to expand</summary>
Hidden content goes here. You can include:
- Lists
- **Bold** and _italic_ text
- Code blocks
- Any markdown content
</details>
### Expanded by default
Add the `open` attribute to start expanded:
```html
<details open>
<summary>Already expanded</summary>
This section starts open. Users can click to collapse it.
</details>
```
<details open>
<summary>Already expanded</summary>
This section starts open. Users can click to collapse it.
</details>
### Toggle with code
```html
<details>
<summary>View the code example</summary>
```typescript
export const getPosts = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("posts").collect();
},
});
```
</details>
```
<details>
<summary>View the code example</summary>
```typescript
export const getPosts = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("posts").collect();
},
});
```
</details>
### Nested toggles
You can nest collapsible sections:
```html
<details>
<summary>Outer section</summary>
Some content here.
<details>
<summary>Inner section</summary>
Nested content inside.
</details>
</details>
```
<details>
<summary>Outer section</summary>
Some content here.
<details>
<summary>Inner section</summary>
Nested content inside.
</details>
</details>
## Multi-line code in lists
Indent code blocks with 4 spaces inside list items:

View File

@@ -0,0 +1,113 @@
---
title: "Netlify edge functions blocking AI crawlers from static files"
description: "Why excludedPath in netlify.toml isn't preventing edge functions from intercepting /raw/* requests, and how ChatGPT and Perplexity get blocked while Claude works."
date: "2025-12-21"
slug: "netlify-edge-excludedpath-ai-crawlers"
published: true
tags: ["netlify", "edge-functions", "ai", "troubleshooting", "help"]
readTime: "4 min read"
featured: false
---
## The problem
AI crawlers cannot access static markdown files at `/raw/*.md` on Netlify, even with `excludedPath` configured. ChatGPT and Perplexity return errors. Claude works.
## What we're building
A markdown blog framework that generates static `.md` files in `public/raw/` during build. Users can share posts with AI tools via a Copy Page dropdown that sends raw markdown URLs.
The goal: AI services fetch `/raw/{slug}.md` and parse clean markdown without HTML.
## The errors
**ChatGPT:**
```
I attempted to load and read the raw markdown at the URL you provided but was unable to fetch the content from that link. The page could not be loaded directly and I cannot access its raw markdown.
```
**Perplexity:**
```
The page could not be loaded with the tools currently available, so its raw markdown content is not accessible.
```
**Claude:**
Works. Loads and reads the markdown successfully.
## Current configuration
Static files exist in `public/raw/` and are served via `_redirects`:
```
/raw/* /raw/:splat 200
```
Edge function configuration in `netlify.toml`:
```toml
[[edge_functions]]
path = "/*"
function = "botMeta"
excludedPath = "/raw/*"
```
The `botMeta` function also has a code-level check:
```typescript
// Skip if it's the home page, static assets, API routes, or raw markdown files
if (
pathParts.length === 0 ||
pathParts[0].includes(".") ||
pathParts[0] === "api" ||
pathParts[0] === "_next" ||
pathParts[0] === "raw" // This check exists
) {
return context.next();
}
```
## Why it's not working
Despite `excludedPath = "/raw/*"` and the code check, the edge function still intercepts requests to `/raw/*.md` before static files are served.
According to Netlify docs, edge functions run before redirects and static file serving. The `excludedPath` should prevent the function from running, but it appears the function still executes and may be returning a response that blocks static file access.
## What we've tried
1. Added `excludedPath = "/raw/*"` in netlify.toml
2. Added code-level check in botMeta.ts to skip `/raw/` paths
3. Verified static files exist in `public/raw/` after build
4. Confirmed `_redirects` rule for `/raw/*` is in place
5. Tested with different URLPattern syntax (`/raw/*`, `/**/*.md`)
All attempts result in the same behavior: ChatGPT and Perplexity cannot access the files, while Claude can.
## Why Claude works
Claude's web fetcher may use different headers or handle Netlify's edge function responses differently. It successfully bypasses whatever is blocking ChatGPT and Perplexity.
## The question
How can we configure Netlify edge functions to truly exclude `/raw/*` paths so static markdown files are served directly to all AI crawlers without interception?
Is there a configuration issue with `excludedPath`? Should we use a different approach like header-based matching to exclude AI crawlers from the botMeta function? Or is there a processing order issue where edge functions always run before static files regardless of exclusions?
## Code reference
The CopyPageDropdown component sends these URLs to AI services:
```typescript
const rawMarkdownUrl = `${origin}/raw/${props.slug}.md`;
```
Example: `https://www.markdown.fast/raw/fork-configuration-guide.md`
The files exist. The redirects are configured. The edge function has exclusions. But AI crawlers still cannot access them.
## Help needed
If you've solved this or have suggestions, we'd appreciate guidance. The goal is simple: serve static markdown files at `/raw/*.md` to all clients, including AI crawlers, without edge function interception.
GitHub raw URLs work as a workaround, but we'd prefer to use Netlify-hosted files for consistency and to avoid requiring users to configure GitHub repo details when forking.

View File

@@ -783,7 +783,7 @@ Logos display in grayscale and colorize on hover.
### Blog page
The site supports a dedicated blog page at `/blog`. Configure in `src/config/siteConfig.ts`:
The site supports a dedicated blog page at `/blog` with two view modes: list view (year-grouped posts) and card view (thumbnail grid). Configure in `src/config/siteConfig.ts`:
```typescript
blogPage: {
@@ -791,6 +791,8 @@ blogPage: {
showInNav: true, // Show in navigation
title: "Blog", // Nav link and page title
order: 0, // Nav order (lower = first)
viewMode: "list", // Default view: "list" or "cards"
showViewToggle: true, // Show toggle button to switch views
},
displayOnHomepage: true, // Show posts on homepage
```
@@ -801,8 +803,19 @@ displayOnHomepage: true, // Show posts on homepage
| `showInNav` | Show Blog link in navigation |
| `title` | Text for nav link and page heading |
| `order` | Position in navigation (lower = first) |
| `viewMode` | Default view: `"list"` or `"cards"` |
| `showViewToggle` | Show toggle button to switch views |
| `displayOnHomepage` | Show post list on homepage |
**View modes:**
- **List view:** Year-grouped posts with titles, read time, and dates
- **Card view:** Grid of cards showing thumbnails, titles, excerpts, and metadata
**Card view details:**
Cards display post thumbnails (from `image` frontmatter field), titles, excerpts (or descriptions), read time, and dates. Posts without images show cards without thumbnail areas. Grid is responsive: 3 columns on desktop, 2 on tablet, 1 on mobile.
**Display options:**
- Homepage only: `displayOnHomepage: true`, `blogPage.enabled: false`
@@ -811,6 +824,8 @@ displayOnHomepage: true, // Show posts on homepage
**Navigation order:** The Blog link merges with page links and sorts by order. Pages use the `order` field in frontmatter. Set `blogPage.order: 5` to position Blog after pages with order 0-4.
**View preference:** User's view mode choice is saved to localStorage and persists across page visits.
### Scroll-to-top button
A scroll-to-top button appears after scrolling down on posts and pages. Configure it in `src/components/Layout.tsx`:
@@ -908,11 +923,14 @@ Your page content here...
| `order` | No | Display order (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 |
3. Run `npm run sync` to sync pages
Pages appear automatically in the navigation when published.
**Sidebar layout:** Add `layout: "sidebar"` to any page frontmatter to enable a docs-style layout with a table of contents sidebar. The sidebar extracts headings (H1, H2, H3) automatically and provides smooth scroll navigation. Only appears if headings exist in the page content.
### Update SEO Meta Tags
Edit `index.html` to update:

View File

@@ -8,7 +8,7 @@ excerpt: "An open-source publishing framework for AI agents and developers."
An open-source publishing framework for AI agents and developers. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.
## What makes it a dev sync system
## What makes it a dev sync system?
**File-based content.** All posts and pages live in `content/blog/` and `content/pages/` as markdown files with frontmatter. No database UI. No admin panel. Just files in your repo.

View File

@@ -3,10 +3,79 @@ title: "Changelog"
slug: "changelog"
published: true
order: 5
layout: "sidebar"
---
All notable changes to this project.
## v1.23.0
Released December 23, 2025
**Collapsible sections in markdown**
- Create expandable/collapsible content using HTML `<details>` and `<summary>` tags
- Use `<details open>` attribute for sections that start expanded by default
- Supports nested collapsible sections for multi-level content
- Theme-aware styling for all four themes (dark, light, tan, cloud)
- Works with all markdown content inside: lists, code blocks, bold, italic, links, etc.
Example usage:
```html
<details>
<summary>Click to expand</summary>
Hidden content here with **markdown** support.
</details>
```
New packages: `rehype-raw`, `rehype-sanitize`
Updated files: `src/components/BlogPost.tsx`, `src/styles/global.css`
Documentation updated: `markdown-with-code-examples.md`, `docs.md`
## v1.22.0
Released December 21, 2025
**Sidebar layout for pages**
- Pages can now use a docs-style layout with table of contents sidebar
- Add `layout: "sidebar"` to page frontmatter to enable
- Left sidebar displays TOC extracted from H1, H2, H3 headings automatically
- Two-column grid layout: 220px sidebar + flexible content area
- Active heading highlighting on scroll
- Smooth scroll navigation to sections
- Sidebar only appears if headings exist in content
- Mobile responsive: stacks to single column below 1024px
- CopyPageDropdown remains in top navigation for sidebar pages
New files: `src/utils/extractHeadings.ts`, `src/components/PageSidebar.tsx`
Updated files: `convex/schema.ts`, `scripts/sync-posts.ts`, `convex/pages.ts`, `src/pages/Post.tsx`, `src/styles/global.css`
## v1.21.0
Released December 21, 2025
**Blog page view mode toggle**
- Blog page now supports two view modes: list view and card view
- Toggle button in blog header switches between views
- List view: year-grouped posts with titles, read time, and dates
- Card view: 3-column grid with thumbnails, titles, excerpts, and metadata
- Default view configurable via `siteConfig.blogPage.viewMode`
- Toggle visibility controlled by `siteConfig.blogPage.showViewToggle`
- View preference saved to localStorage and persists across visits
- Responsive grid: 3 columns (desktop), 2 columns (tablet), 1 column (mobile)
- Theme-aware styling for all four themes (dark, light, tan, cloud)
- Cards display post thumbnails from `image` frontmatter field
- Posts without images show cards without thumbnail areas
Updated files: `src/pages/Blog.tsx`, `src/components/PostList.tsx`, `src/config/siteConfig.ts`, `src/styles/global.css`
## v1.20.3
Released December 21, 2025

View File

@@ -3,6 +3,7 @@ title: "Docs"
slug: "docs"
published: true
order: 0
layout: "sidebar"
---
Reference documentation for setting up, customizing, and deploying this markdown framework.
@@ -82,15 +83,15 @@ 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 |
| 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 |
@@ -113,18 +114,51 @@ order: 1
Content here...
```
| Field | Required | Description |
| --------------- | -------- | ---------------------------------- |
| `title` | Yes | Nav link text |
| `slug` | Yes | URL path |
| `published` | Yes | `true` to show |
| `order` | No | Nav order (lower = first) |
| `excerpt` | No | Short text for card view |
| `image` | No | Thumbnail for featured 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 |
| Field | Required | Description |
| --------------- | -------- | ------------------------------------------------- |
| `title` | Yes | Nav link text |
| `slug` | Yes | URL path |
| `published` | Yes | `true` to show |
| `order` | No | Nav order (lower = first) |
| `excerpt` | No | Short text for card view |
| `image` | No | Thumbnail for featured 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 |
### Sidebar layout
Pages can use a docs-style layout with a table of contents sidebar. Add `layout: "sidebar"` to the page frontmatter:
```markdown
---
title: "Documentation"
slug: "docs"
published: true
layout: "sidebar"
---
# Introduction
## Section One
### Subsection
## Section Two
```
**Features:**
- Left sidebar displays table of contents extracted from H1, H2, H3 headings
- Two-column layout: 220px sidebar + flexible content area
- Sidebar only appears if headings exist in the page content
- Active heading highlighting as you scroll
- Smooth scroll navigation when clicking TOC links
- Mobile responsive: stacks to single column below 1024px
The sidebar extracts headings automatically from your markdown content. No manual TOC needed.
### How frontmatter works
@@ -216,20 +250,20 @@ Follow the step-by-step guide in `FORK_CONFIG.md` to update each file manually.
These files contain the main site description text. Update them with your own tagline:
| File | What to change |
|------|----------------|
| `index.html` | meta description, og:description, twitter:description, JSON-LD |
| `README.md` | Main description at top of file |
| `src/config/siteConfig.ts` | name, title, and bio fields |
| `src/pages/Home.tsx` | Intro paragraph (hardcoded JSX with links) |
| `convex/http.ts` | SITE_NAME constant and description strings (3 locations) |
| `convex/rss.ts` | SITE_TITLE and SITE_DESCRIPTION constants |
| `public/llms.txt` | Header quote, Name, and Description fields |
| `public/openapi.yaml` | API title and example site name |
| `AGENTS.md` | Project overview section |
| `content/blog/about-this-blog.md` | Title, description, excerpt, and opening paragraph |
| `content/pages/about.md` | excerpt field and opening paragraph |
| `content/pages/docs.md` | Opening description paragraph |
| File | What to change |
| --------------------------------- | -------------------------------------------------------------- |
| `index.html` | meta description, og:description, twitter:description, JSON-LD |
| `README.md` | Main description at top of file |
| `src/config/siteConfig.ts` | name, title, and bio fields |
| `src/pages/Home.tsx` | Intro paragraph (hardcoded JSX with links) |
| `convex/http.ts` | SITE_NAME constant and description strings (3 locations) |
| `convex/rss.ts` | SITE_TITLE and SITE_DESCRIPTION constants |
| `public/llms.txt` | Header quote, Name, and Description fields |
| `public/openapi.yaml` | API title and example site name |
| `AGENTS.md` | Project overview section |
| `content/blog/about-this-blog.md` | Title, description, excerpt, and opening paragraph |
| `content/pages/about.md` | excerpt field and opening paragraph |
| `content/pages/docs.md` | Opening description paragraph |
**Backend constants** (`convex/http.ts` and `convex/rss.ts`):
@@ -268,10 +302,10 @@ export default {
// Blog page configuration
blogPage: {
enabled: true, // Enable /blog route
showInNav: true, // Show in navigation
title: "Blog", // Nav link and page title
order: 0, // Nav order (lower = first)
enabled: true, // Enable /blog route
showInNav: true, // Show in navigation
title: "Blog", // Nav link and page title
order: 0, // Nav order (lower = first)
},
displayOnHomepage: true, // Show posts on homepage
@@ -350,13 +384,13 @@ gitHubContributions: {
},
```
| Option | Description |
| ------ | ----------- |
| `enabled` | `true` to show, `false` to hide |
| `username` | Your GitHub username |
| `showYearNavigation` | Show prev/next year navigation |
| `linkToProfile` | Click graph to visit GitHub profile |
| `title` | Text above graph (`undefined` to hide) |
| Option | Description |
| -------------------- | -------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `username` | Your GitHub username |
| `showYearNavigation` | Show prev/next year navigation |
| `linkToProfile` | Click graph to visit GitHub profile |
| `title` | Text above graph (`undefined` to hide) |
Theme-aware colors match each site theme. Uses public API (no GitHub token required).
@@ -371,10 +405,10 @@ visitorMap: {
},
```
| Option | Description |
| --------- | ------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `title` | Text above map (`undefined` to hide) |
| Option | Description |
| --------- | ------------------------------------ |
| `enabled` | `true` to show, `false` to hide |
| `title` | Text above map (`undefined` to hide) |
The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge.
@@ -398,14 +432,14 @@ logoGallery: {
},
```
| Option | Description |
| ----------- | -------------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `images` | Array of `{ src, href }` objects |
| `position` | `'above-footer'` or `'below-featured'` |
| `speed` | Seconds for one scroll cycle (lower = faster) |
| `title` | Text above gallery (`undefined` to hide) |
| `scrolling` | `true` for infinite scroll, `false` for static grid |
| Option | Description |
| ----------- | ---------------------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `images` | Array of `{ src, href }` objects |
| `position` | `'above-footer'` or `'below-featured'` |
| `speed` | Seconds for one scroll cycle (lower = faster) |
| `title` | Text above gallery (`undefined` to hide) |
| `scrolling` | `true` for infinite scroll, `false` for static grid |
| `maxItems` | Max logos to show when `scrolling` is `false` (default: 4) |
**Display modes:**
@@ -425,7 +459,7 @@ logoGallery: {
### Blog page
The site supports a dedicated blog page at `/blog`. Configure in `src/config/siteConfig.ts`:
The site supports a dedicated blog page at `/blog` with two view modes: list view (year-grouped posts) and card view (thumbnail grid). Configure in `src/config/siteConfig.ts`:
```typescript
blogPage: {
@@ -433,6 +467,8 @@ blogPage: {
showInNav: true, // Show in navigation
title: "Blog", // Nav link and page title
order: 0, // Nav order (lower = first)
viewMode: "list", // Default view: "list" or "cards"
showViewToggle: true, // Show toggle button to switch views
},
displayOnHomepage: true, // Show posts on homepage
```
@@ -443,8 +479,19 @@ displayOnHomepage: true, // Show posts on homepage
| `showInNav` | Show Blog link in navigation |
| `title` | Text for nav link and page heading |
| `order` | Position in navigation (lower = first) |
| `viewMode` | Default view: `"list"` or `"cards"` |
| `showViewToggle` | Show toggle button to switch views |
| `displayOnHomepage` | Show post list on homepage |
**View modes:**
- **List view:** Year-grouped posts with titles, read time, and dates
- **Card view:** Grid of cards showing thumbnails, titles, excerpts, and metadata
**Card view details:**
Cards display post thumbnails (from `image` frontmatter field), titles, excerpts (or descriptions), read time, and dates. Posts without images show cards without thumbnail areas. Grid is responsive: 3 columns on desktop, 2 on tablet, 1 on mobile.
**Display options:**
- Homepage only: `displayOnHomepage: true`, `blogPage.enabled: false`
@@ -453,6 +500,8 @@ displayOnHomepage: true, // Show posts on homepage
**Navigation order:** The Blog link merges with page links and sorts by order. Pages use the `order` field in frontmatter. Set `blogPage.order: 5` to position Blog after pages with order 0-4.
**View preference:** User's view mode choice is saved to localStorage and persists across page visits.
### Scroll-to-top button
A scroll-to-top button appears after scrolling down. Configure in `src/components/Layout.tsx`:
@@ -559,14 +608,14 @@ The menu appears automatically on screens under 768px wide.
Each post and page includes a share dropdown with options:
| Option | Description |
| ---------------- | ------------------------------------------------ |
| Copy page | Copies formatted markdown to clipboard |
| Open in ChatGPT | Opens ChatGPT with raw markdown URL |
| Open in Claude | Opens Claude with raw markdown URL |
| Open in Perplexity | Opens Perplexity with raw markdown URL |
| View as Markdown | Opens raw `.md` file in new tab |
| Generate Skill | Downloads `{slug}-skill.md` for AI agent training |
| Option | Description |
| ------------------ | ------------------------------------------------- |
| Copy page | Copies formatted markdown to clipboard |
| Open in ChatGPT | Opens ChatGPT with raw markdown URL |
| Open in Claude | Opens Claude with raw markdown URL |
| Open in Perplexity | Opens Perplexity with raw markdown URL |
| View as Markdown | Opens raw `.md` file in new tab |
| Generate Skill | Downloads `{slug}-skill.md` for AI agent training |
**Raw markdown URLs:** AI services receive the URL to the raw markdown file (e.g., `/raw/setup-guide.md`) instead of the page URL. This provides direct access to clean markdown content with metadata headers for better AI parsing.
@@ -630,6 +679,33 @@ Example:
| Mobile | Scroll |
| Themes | All |
## Collapsible sections
Create expandable/collapsible content using HTML `<details>` and `<summary>` tags:
```html
<details>
<summary>Click to expand</summary>
Hidden content here. Supports markdown: - Lists - **Bold** and _italic_ - Code
blocks
</details>
```
**Expanded by default:** Add the `open` attribute:
```html
<details open>
<summary>Already expanded</summary>
This section starts open.
</details>
```
**Nested sections:** You can nest `<details>` inside other `<details>` for multi-level collapsible content.
Collapsible sections work with all four themes and are styled to match the site design.
## Import external content
Use Firecrawl to import articles from external URLs:

View File

@@ -17,6 +17,7 @@ export const getAllPages = query({
featuredOrder: v.optional(v.number()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
layout: v.optional(v.string()),
}),
),
handler: async (ctx) => {
@@ -45,6 +46,7 @@ export const getAllPages = query({
featuredOrder: page.featuredOrder,
authorName: page.authorName,
authorImage: page.authorImage,
layout: page.layout,
}));
},
});
@@ -107,6 +109,7 @@ export const getPageBySlug = query({
featuredOrder: v.optional(v.number()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
layout: v.optional(v.string()),
}),
v.null(),
),
@@ -133,6 +136,7 @@ export const getPageBySlug = query({
featuredOrder: page.featuredOrder,
authorName: page.authorName,
authorImage: page.authorImage,
layout: page.layout,
};
},
});
@@ -153,6 +157,7 @@ export const syncPagesPublic = mutation({
featuredOrder: v.optional(v.number()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
layout: v.optional(v.string()),
}),
),
},
@@ -190,6 +195,7 @@ export const syncPagesPublic = mutation({
featuredOrder: page.featuredOrder,
authorName: page.authorName,
authorImage: page.authorImage,
layout: page.layout,
lastSyncedAt: now,
});
updated++;

View File

@@ -46,6 +46,7 @@ export default defineSchema({
featuredOrder: v.optional(v.number()), // Order in featured section (lower = first)
authorName: v.optional(v.string()), // Author display name
authorImage: v.optional(v.string()), // Author avatar image URL (round)
layout: v.optional(v.string()), // Layout type: "sidebar" for docs-style layout
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])

View File

@@ -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 |
| `Blog.tsx` | Dedicated blog page with post list (configurable via siteConfig.blogPage) |
| `Blog.tsx` | Dedicated blog page with post list or card grid view (configurable via siteConfig.blogPage, supports view toggle) |
| `Post.tsx` | Individual blog post view (update SITE_URL/SITE_NAME when forking) |
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars |
| `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans-serif), and localStorage persistence (not linked in nav) |
@@ -51,8 +51,8 @@ A brief description of each file in the codebase.
| ------------------------- | ---------------------------------------------------------- |
| `Layout.tsx` | Page wrapper with search button, theme toggle, mobile menu, and scroll-to-top |
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
| `PostList.tsx` | Year-grouped blog post list |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting |
| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes) |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting and collapsible sections (details/summary) |
| `CopyPageDropdown.tsx` | Share dropdown for LLMs (ChatGPT, Claude, Perplexity) using raw markdown URLs for better AI parsing, with View as Markdown and Generate Skill options |
| `SearchModal.tsx` | Full text search modal with keyboard navigation |
| `FeaturedCards.tsx` | Card grid for featured posts/pages with excerpts |

198
package-lock.json generated
View File

@@ -21,6 +21,8 @@
"react-markdown": "^9.0.1",
"react-router-dom": "^6.22.0",
"react-syntax-highlighter": "^15.5.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0"
},
@@ -2282,6 +2284,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -3104,6 +3118,56 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"hastscript": "^9.0.0",
"property-information": "^7.0.0",
"vfile": "^6.0.0",
"vfile-location": "^5.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-parse5/node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-parse5/node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
@@ -3114,6 +3178,46 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-raw": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"hast-util-from-parse5": "^8.0.0",
"hast-util-to-parse5": "^8.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"parse5": "^7.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-sanitize": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"unist-util-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -3141,6 +3245,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -3244,6 +3367,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4689,6 +4822,18 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5086,6 +5231,35 @@
"node": ">=6"
}
},
"node_modules/rehype-raw": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-raw": "^9.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-sanitize": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-sanitize": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-breaks": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
@@ -6190,6 +6364,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-location": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -6694,6 +6882,16 @@
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -31,6 +31,8 @@
"react-markdown": "^9.0.1",
"react-router-dom": "^6.22.0",
"react-syntax-highlighter": "^15.5.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0"
},

View File

@@ -2,12 +2,12 @@
---
Type: page
Date: 2025-12-22
Date: 2025-12-23
---
An open-source publishing framework for AI agents and developers. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.
## What makes it a dev sync system
## What makes it a dev sync system?
**File-based content.** All posts and pages live in `content/blog/` and `content/pages/` as markdown files with frontmatter. No database UI. No admin panel. Just files in your repo.

View File

@@ -2,11 +2,79 @@
---
Type: page
Date: 2025-12-22
Date: 2025-12-23
---
All notable changes to this project.
## v1.23.0
Released December 23, 2025
**Collapsible sections in markdown**
- Create expandable/collapsible content using HTML `<details>` and `<summary>` tags
- Use `<details open>` attribute for sections that start expanded by default
- Supports nested collapsible sections for multi-level content
- Theme-aware styling for all four themes (dark, light, tan, cloud)
- Works with all markdown content inside: lists, code blocks, bold, italic, links, etc.
Example usage:
```html
<details>
<summary>Click to expand</summary>
Hidden content here with **markdown** support.
</details>
```
New packages: `rehype-raw`, `rehype-sanitize`
Updated files: `src/components/BlogPost.tsx`, `src/styles/global.css`
Documentation updated: `markdown-with-code-examples.md`, `docs.md`
## v1.22.0
Released December 21, 2025
**Sidebar layout for pages**
- Pages can now use a docs-style layout with table of contents sidebar
- Add `layout: "sidebar"` to page frontmatter to enable
- Left sidebar displays TOC extracted from H1, H2, H3 headings automatically
- Two-column grid layout: 220px sidebar + flexible content area
- Active heading highlighting on scroll
- Smooth scroll navigation to sections
- Sidebar only appears if headings exist in content
- Mobile responsive: stacks to single column below 1024px
- CopyPageDropdown remains in top navigation for sidebar pages
New files: `src/utils/extractHeadings.ts`, `src/components/PageSidebar.tsx`
Updated files: `convex/schema.ts`, `scripts/sync-posts.ts`, `convex/pages.ts`, `src/pages/Post.tsx`, `src/styles/global.css`
## v1.21.0
Released December 21, 2025
**Blog page view mode toggle**
- Blog page now supports two view modes: list view and card view
- Toggle button in blog header switches between views
- List view: year-grouped posts with titles, read time, and dates
- Card view: 3-column grid with thumbnails, titles, excerpts, and metadata
- Default view configurable via `siteConfig.blogPage.viewMode`
- Toggle visibility controlled by `siteConfig.blogPage.showViewToggle`
- View preference saved to localStorage and persists across visits
- Responsive grid: 3 columns (desktop), 2 columns (tablet), 1 column (mobile)
- Theme-aware styling for all four themes (dark, light, tan, cloud)
- Cards display post thumbnails from `image` frontmatter field
- Posts without images show cards without thumbnail areas
Updated files: `src/pages/Blog.tsx`, `src/components/PostList.tsx`, `src/config/siteConfig.ts`, `src/styles/global.css`
## v1.20.3
Released December 21, 2025

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2025-12-22
Date: 2025-12-23
---
You found the contact page. Nice

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2025-12-22
Date: 2025-12-23
---
Reference documentation for setting up, customizing, and deploying this markdown framework.
@@ -82,15 +82,15 @@ 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 |
| 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 |
@@ -113,18 +113,51 @@ order: 1
Content here...
```
| Field | Required | Description |
| --------------- | -------- | ---------------------------------- |
| `title` | Yes | Nav link text |
| `slug` | Yes | URL path |
| `published` | Yes | `true` to show |
| `order` | No | Nav order (lower = first) |
| `excerpt` | No | Short text for card view |
| `image` | No | Thumbnail for featured 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 |
| Field | Required | Description |
| --------------- | -------- | ------------------------------------------------- |
| `title` | Yes | Nav link text |
| `slug` | Yes | URL path |
| `published` | Yes | `true` to show |
| `order` | No | Nav order (lower = first) |
| `excerpt` | No | Short text for card view |
| `image` | No | Thumbnail for featured 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 |
### Sidebar layout
Pages can use a docs-style layout with a table of contents sidebar. Add `layout: "sidebar"` to the page frontmatter:
```markdown
---
title: "Documentation"
slug: "docs"
published: true
layout: "sidebar"
---
# Introduction
## Section One
### Subsection
## Section Two
```
**Features:**
- Left sidebar displays table of contents extracted from H1, H2, H3 headings
- Two-column layout: 220px sidebar + flexible content area
- Sidebar only appears if headings exist in the page content
- Active heading highlighting as you scroll
- Smooth scroll navigation when clicking TOC links
- Mobile responsive: stacks to single column below 1024px
The sidebar extracts headings automatically from your markdown content. No manual TOC needed.
### How frontmatter works
@@ -216,20 +249,20 @@ Follow the step-by-step guide in `FORK_CONFIG.md` to update each file manually.
These files contain the main site description text. Update them with your own tagline:
| File | What to change |
|------|----------------|
| `index.html` | meta description, og:description, twitter:description, JSON-LD |
| `README.md` | Main description at top of file |
| `src/config/siteConfig.ts` | name, title, and bio fields |
| `src/pages/Home.tsx` | Intro paragraph (hardcoded JSX with links) |
| `convex/http.ts` | SITE_NAME constant and description strings (3 locations) |
| `convex/rss.ts` | SITE_TITLE and SITE_DESCRIPTION constants |
| `public/llms.txt` | Header quote, Name, and Description fields |
| `public/openapi.yaml` | API title and example site name |
| `AGENTS.md` | Project overview section |
| `content/blog/about-this-blog.md` | Title, description, excerpt, and opening paragraph |
| `content/pages/about.md` | excerpt field and opening paragraph |
| `content/pages/docs.md` | Opening description paragraph |
| File | What to change |
| --------------------------------- | -------------------------------------------------------------- |
| `index.html` | meta description, og:description, twitter:description, JSON-LD |
| `README.md` | Main description at top of file |
| `src/config/siteConfig.ts` | name, title, and bio fields |
| `src/pages/Home.tsx` | Intro paragraph (hardcoded JSX with links) |
| `convex/http.ts` | SITE_NAME constant and description strings (3 locations) |
| `convex/rss.ts` | SITE_TITLE and SITE_DESCRIPTION constants |
| `public/llms.txt` | Header quote, Name, and Description fields |
| `public/openapi.yaml` | API title and example site name |
| `AGENTS.md` | Project overview section |
| `content/blog/about-this-blog.md` | Title, description, excerpt, and opening paragraph |
| `content/pages/about.md` | excerpt field and opening paragraph |
| `content/pages/docs.md` | Opening description paragraph |
**Backend constants** (`convex/http.ts` and `convex/rss.ts`):
@@ -268,10 +301,10 @@ export default {
// Blog page configuration
blogPage: {
enabled: true, // Enable /blog route
showInNav: true, // Show in navigation
title: "Blog", // Nav link and page title
order: 0, // Nav order (lower = first)
enabled: true, // Enable /blog route
showInNav: true, // Show in navigation
title: "Blog", // Nav link and page title
order: 0, // Nav order (lower = first)
},
displayOnHomepage: true, // Show posts on homepage
@@ -350,13 +383,13 @@ gitHubContributions: {
},
```
| Option | Description |
| ------ | ----------- |
| `enabled` | `true` to show, `false` to hide |
| `username` | Your GitHub username |
| `showYearNavigation` | Show prev/next year navigation |
| `linkToProfile` | Click graph to visit GitHub profile |
| `title` | Text above graph (`undefined` to hide) |
| Option | Description |
| -------------------- | -------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `username` | Your GitHub username |
| `showYearNavigation` | Show prev/next year navigation |
| `linkToProfile` | Click graph to visit GitHub profile |
| `title` | Text above graph (`undefined` to hide) |
Theme-aware colors match each site theme. Uses public API (no GitHub token required).
@@ -371,10 +404,10 @@ visitorMap: {
},
```
| Option | Description |
| --------- | ------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `title` | Text above map (`undefined` to hide) |
| Option | Description |
| --------- | ------------------------------------ |
| `enabled` | `true` to show, `false` to hide |
| `title` | Text above map (`undefined` to hide) |
The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge.
@@ -398,14 +431,14 @@ logoGallery: {
},
```
| Option | Description |
| ----------- | -------------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `images` | Array of `{ src, href }` objects |
| `position` | `'above-footer'` or `'below-featured'` |
| `speed` | Seconds for one scroll cycle (lower = faster) |
| `title` | Text above gallery (`undefined` to hide) |
| `scrolling` | `true` for infinite scroll, `false` for static grid |
| Option | Description |
| ----------- | ---------------------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `images` | Array of `{ src, href }` objects |
| `position` | `'above-footer'` or `'below-featured'` |
| `speed` | Seconds for one scroll cycle (lower = faster) |
| `title` | Text above gallery (`undefined` to hide) |
| `scrolling` | `true` for infinite scroll, `false` for static grid |
| `maxItems` | Max logos to show when `scrolling` is `false` (default: 4) |
**Display modes:**
@@ -425,7 +458,7 @@ logoGallery: {
### Blog page
The site supports a dedicated blog page at `/blog`. Configure in `src/config/siteConfig.ts`:
The site supports a dedicated blog page at `/blog` with two view modes: list view (year-grouped posts) and card view (thumbnail grid). Configure in `src/config/siteConfig.ts`:
```typescript
blogPage: {
@@ -433,6 +466,8 @@ blogPage: {
showInNav: true, // Show in navigation
title: "Blog", // Nav link and page title
order: 0, // Nav order (lower = first)
viewMode: "list", // Default view: "list" or "cards"
showViewToggle: true, // Show toggle button to switch views
},
displayOnHomepage: true, // Show posts on homepage
```
@@ -443,8 +478,19 @@ displayOnHomepage: true, // Show posts on homepage
| `showInNav` | Show Blog link in navigation |
| `title` | Text for nav link and page heading |
| `order` | Position in navigation (lower = first) |
| `viewMode` | Default view: `"list"` or `"cards"` |
| `showViewToggle` | Show toggle button to switch views |
| `displayOnHomepage` | Show post list on homepage |
**View modes:**
- **List view:** Year-grouped posts with titles, read time, and dates
- **Card view:** Grid of cards showing thumbnails, titles, excerpts, and metadata
**Card view details:**
Cards display post thumbnails (from `image` frontmatter field), titles, excerpts (or descriptions), read time, and dates. Posts without images show cards without thumbnail areas. Grid is responsive: 3 columns on desktop, 2 on tablet, 1 on mobile.
**Display options:**
- Homepage only: `displayOnHomepage: true`, `blogPage.enabled: false`
@@ -453,6 +499,8 @@ displayOnHomepage: true, // Show posts on homepage
**Navigation order:** The Blog link merges with page links and sorts by order. Pages use the `order` field in frontmatter. Set `blogPage.order: 5` to position Blog after pages with order 0-4.
**View preference:** User's view mode choice is saved to localStorage and persists across page visits.
### Scroll-to-top button
A scroll-to-top button appears after scrolling down. Configure in `src/components/Layout.tsx`:
@@ -559,14 +607,14 @@ The menu appears automatically on screens under 768px wide.
Each post and page includes a share dropdown with options:
| Option | Description |
| ---------------- | ------------------------------------------------ |
| Copy page | Copies formatted markdown to clipboard |
| Open in ChatGPT | Opens ChatGPT with raw markdown URL |
| Open in Claude | Opens Claude with raw markdown URL |
| Open in Perplexity | Opens Perplexity with raw markdown URL |
| View as Markdown | Opens raw `.md` file in new tab |
| Generate Skill | Downloads `{slug}-skill.md` for AI agent training |
| Option | Description |
| ------------------ | ------------------------------------------------- |
| Copy page | Copies formatted markdown to clipboard |
| Open in ChatGPT | Opens ChatGPT with raw markdown URL |
| Open in Claude | Opens Claude with raw markdown URL |
| Open in Perplexity | Opens Perplexity with raw markdown URL |
| View as Markdown | Opens raw `.md` file in new tab |
| Generate Skill | Downloads `{slug}-skill.md` for AI agent training |
**Raw markdown URLs:** AI services receive the URL to the raw markdown file (e.g., `/raw/setup-guide.md`) instead of the page URL. This provides direct access to clean markdown content with metadata headers for better AI parsing.
@@ -630,6 +678,33 @@ Example:
| Mobile | Scroll |
| Themes | All |
## Collapsible sections
Create expandable/collapsible content using HTML `<details>` and `<summary>` tags:
```html
<details>
<summary>Click to expand</summary>
Hidden content here. Supports markdown: - Lists - **Bold** and _italic_ - Code
blocks
</details>
```
**Expanded by default:** Add the `open` attribute:
```html
<details open>
<summary>Already expanded</summary>
This section starts open.
</details>
```
**Nested sections:** You can nest `<details>` inside other `<details>` for multi-level collapsible content.
Collapsible sections work with all four themes and are styled to match the site design.
## Import external content
Use Firecrawl to import articles from external URLs:

View File

@@ -378,6 +378,124 @@ Control column alignment with colons:
| :--- | :----: | ----: |
| L | C | R |
## Collapsible sections
Use HTML `<details>` and `<summary>` tags to create expandable/collapsible content:
### Basic toggle
```html
<details>
<summary>Click to expand</summary>
Hidden content goes here. You can include:
- Lists
- **Bold** and _italic_ text
- Code blocks
- Any markdown content
</details>
```
<details>
<summary>Click to expand</summary>
Hidden content goes here. You can include:
- Lists
- **Bold** and _italic_ text
- Code blocks
- Any markdown content
</details>
### Expanded by default
Add the `open` attribute to start expanded:
```html
<details open>
<summary>Already expanded</summary>
This section starts open. Users can click to collapse it.
</details>
```
<details open>
<summary>Already expanded</summary>
This section starts open. Users can click to collapse it.
</details>
### Toggle with code
```html
<details>
<summary>View the code example</summary>
```typescript
export const getPosts = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("posts").collect();
},
});
```
</details>
```
<details>
<summary>View the code example</summary>
```typescript
export const getPosts = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("posts").collect();
},
});
```
</details>
### Nested toggles
You can nest collapsible sections:
```html
<details>
<summary>Outer section</summary>
Some content here.
<details>
<summary>Inner section</summary>
Nested content inside.
</details>
</details>
```
<details>
<summary>Outer section</summary>
Some content here.
<details>
<summary>Inner section</summary>
Nested content inside.
</details>
</details>
## Multi-line code in lists
Indent code blocks with 4 spaces inside list items:

View File

@@ -0,0 +1,113 @@
# Netlify edge functions blocking AI crawlers from static files
> Why excludedPath in netlify.toml isn't preventing edge functions from intercepting /raw/* requests, and how ChatGPT and Perplexity get blocked while Claude works.
---
Type: post
Date: 2025-12-21
Reading time: 4 min read
Tags: netlify, edge-functions, ai, troubleshooting, help
---
## The problem
AI crawlers cannot access static markdown files at `/raw/*.md` on Netlify, even with `excludedPath` configured. ChatGPT and Perplexity return errors. Claude works.
## What we're building
A markdown blog framework that generates static `.md` files in `public/raw/` during build. Users can share posts with AI tools via a Copy Page dropdown that sends raw markdown URLs.
The goal: AI services fetch `/raw/{slug}.md` and parse clean markdown without HTML.
## The errors
**ChatGPT:**
```
I attempted to load and read the raw markdown at the URL you provided but was unable to fetch the content from that link. The page could not be loaded directly and I cannot access its raw markdown.
```
**Perplexity:**
```
The page could not be loaded with the tools currently available, so its raw markdown content is not accessible.
```
**Claude:**
Works. Loads and reads the markdown successfully.
## Current configuration
Static files exist in `public/raw/` and are served via `_redirects`:
```
/raw/* /raw/:splat 200
```
Edge function configuration in `netlify.toml`:
```toml
[[edge_functions]]
path = "/*"
function = "botMeta"
excludedPath = "/raw/*"
```
The `botMeta` function also has a code-level check:
```typescript
// Skip if it's the home page, static assets, API routes, or raw markdown files
if (
pathParts.length === 0 ||
pathParts[0].includes(".") ||
pathParts[0] === "api" ||
pathParts[0] === "_next" ||
pathParts[0] === "raw" // This check exists
) {
return context.next();
}
```
## Why it's not working
Despite `excludedPath = "/raw/*"` and the code check, the edge function still intercepts requests to `/raw/*.md` before static files are served.
According to Netlify docs, edge functions run before redirects and static file serving. The `excludedPath` should prevent the function from running, but it appears the function still executes and may be returning a response that blocks static file access.
## What we've tried
1. Added `excludedPath = "/raw/*"` in netlify.toml
2. Added code-level check in botMeta.ts to skip `/raw/` paths
3. Verified static files exist in `public/raw/` after build
4. Confirmed `_redirects` rule for `/raw/*` is in place
5. Tested with different URLPattern syntax (`/raw/*`, `/**/*.md`)
All attempts result in the same behavior: ChatGPT and Perplexity cannot access the files, while Claude can.
## Why Claude works
Claude's web fetcher may use different headers or handle Netlify's edge function responses differently. It successfully bypasses whatever is blocking ChatGPT and Perplexity.
## The question
How can we configure Netlify edge functions to truly exclude `/raw/*` paths so static markdown files are served directly to all AI crawlers without interception?
Is there a configuration issue with `excludedPath`? Should we use a different approach like header-based matching to exclude AI crawlers from the botMeta function? Or is there a processing order issue where edge functions always run before static files regardless of exclusions?
## Code reference
The CopyPageDropdown component sends these URLs to AI services:
```typescript
const rawMarkdownUrl = `${origin}/raw/${props.slug}.md`;
```
Example: `https://www.markdown.fast/raw/fork-configuration-guide.md`
The files exist. The redirects are configured. The edge function has exclusions. But AI crawlers still cannot access them.
## Help needed
If you've solved this or have suggestions, we'd appreciate guidance. The goal is simple: serve static markdown files at `/raw/*.md` to all clients, including AI crawlers, without edge function interception.
GitHub raw URLs work as a workaround, but we'd prefer to use Netlify-hosted files for consistency and to avoid requiring users to configure GitHub repo details when forking.

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2025-12-22
Date: 2025-12-23
---
This markdown framework is open source and built to be extended. Here is what ships out of the box.

View File

@@ -778,7 +778,7 @@ Logos display in grayscale and colorize on hover.
### Blog page
The site supports a dedicated blog page at `/blog`. Configure in `src/config/siteConfig.ts`:
The site supports a dedicated blog page at `/blog` with two view modes: list view (year-grouped posts) and card view (thumbnail grid). Configure in `src/config/siteConfig.ts`:
```typescript
blogPage: {
@@ -786,6 +786,8 @@ blogPage: {
showInNav: true, // Show in navigation
title: "Blog", // Nav link and page title
order: 0, // Nav order (lower = first)
viewMode: "list", // Default view: "list" or "cards"
showViewToggle: true, // Show toggle button to switch views
},
displayOnHomepage: true, // Show posts on homepage
```
@@ -796,8 +798,19 @@ displayOnHomepage: true, // Show posts on homepage
| `showInNav` | Show Blog link in navigation |
| `title` | Text for nav link and page heading |
| `order` | Position in navigation (lower = first) |
| `viewMode` | Default view: `"list"` or `"cards"` |
| `showViewToggle` | Show toggle button to switch views |
| `displayOnHomepage` | Show post list on homepage |
**View modes:**
- **List view:** Year-grouped posts with titles, read time, and dates
- **Card view:** Grid of cards showing thumbnails, titles, excerpts, and metadata
**Card view details:**
Cards display post thumbnails (from `image` frontmatter field), titles, excerpts (or descriptions), read time, and dates. Posts without images show cards without thumbnail areas. Grid is responsive: 3 columns on desktop, 2 on tablet, 1 on mobile.
**Display options:**
- Homepage only: `displayOnHomepage: true`, `blogPage.enabled: false`
@@ -806,6 +819,8 @@ displayOnHomepage: true, // Show posts on homepage
**Navigation order:** The Blog link merges with page links and sorts by order. Pages use the `order` field in frontmatter. Set `blogPage.order: 5` to position Blog after pages with order 0-4.
**View preference:** User's view mode choice is saved to localStorage and persists across page visits.
### Scroll-to-top button
A scroll-to-top button appears after scrolling down on posts and pages. Configure it in `src/components/Layout.tsx`:
@@ -903,11 +918,14 @@ Your page content here...
| `order` | No | Display order (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 |
3. Run `npm run sync` to sync pages
Pages appear automatically in the navigation when published.
**Sidebar layout:** Add `layout: "sidebar"` to any page frontmatter to enable a docs-style layout with a table of contents sidebar. The sidebar extracts headings (H1, H2, H3) automatically and provides smooth scroll navigation. Only appears if headings exist in the page content.
### Update SEO Meta Tags
Edit `index.html` to update:

View File

@@ -67,6 +67,7 @@ interface PageFrontmatter {
featuredOrder?: number; // Order in featured section (lower = first)
authorName?: string; // Author display name
authorImage?: string; // Author avatar image URL (round)
layout?: string; // Layout type: "sidebar" for docs-style layout
}
interface ParsedPage {
@@ -81,6 +82,7 @@ interface ParsedPage {
featuredOrder?: number; // Order in featured section (lower = first)
authorName?: string; // Author display name
authorImage?: string; // Author avatar image URL (round)
layout?: string; // Layout type: "sidebar" for docs-style layout
}
// Calculate reading time based on word count
@@ -169,6 +171,7 @@ function parsePageFile(filePath: string): ParsedPage | null {
featuredOrder: frontmatter.featuredOrder, // Order in featured section
authorName: frontmatter.authorName, // Author display name
authorImage: frontmatter.authorImage, // Author avatar image URL
layout: frontmatter.layout, // Layout type: "sidebar" for docs-style layout
};
} catch (error) {
console.error(`Error parsing page ${filePath}:`, error);

View File

@@ -2,10 +2,22 @@ import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { Copy, Check } from "lucide-react";
import { useTheme } from "../context/ThemeContext";
// Sanitize schema that allows collapsible sections (details/summary)
const sanitizeSchema = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames || []), "details", "summary"],
attributes: {
...defaultSchema.attributes,
details: ["open"], // Allow the 'open' attribute for expanded by default
},
};
// Copy button component for code blocks
function CodeCopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
@@ -293,6 +305,7 @@ export default function BlogPost({ content }: BlogPostProps) {
<article className="blog-post-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { Heading } from "../utils/extractHeadings";
interface PageSidebarProps {
headings: Heading[];
activeId?: string;
}
export default function PageSidebar({ headings, activeId }: PageSidebarProps) {
const [activeHeading, setActiveHeading] = useState<string | undefined>(activeId);
// Update active heading on scroll
useEffect(() => {
if (headings.length === 0) return;
const handleScroll = () => {
const scrollPosition = window.scrollY + 100; // Offset for header
// Find the heading that's currently in view
for (let i = headings.length - 1; i >= 0; i--) {
const element = document.getElementById(headings[i].id);
if (element && element.offsetTop <= scrollPosition) {
setActiveHeading(headings[i].id);
break;
}
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll(); // Initial check
return () => window.removeEventListener("scroll", handleScroll);
}, [headings]);
if (headings.length === 0) return null;
return (
<nav className="page-sidebar">
<ul className="page-sidebar-list">
{headings.map((heading) => (
<li
key={heading.id}
className={`page-sidebar-item page-sidebar-item-level-${heading.level} ${
activeHeading === heading.id ? "active" : ""
}`}
>
<a
href={`#${heading.id}`}
onClick={(e) => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
// Update URL without scrolling
window.history.pushState(null, "", `#${heading.id}`);
}
}}
className="page-sidebar-link"
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
);
}

View File

@@ -9,10 +9,13 @@ interface Post {
date: string;
readTime?: string;
tags: string[];
excerpt?: string;
image?: string;
}
interface PostListProps {
posts: Post[];
viewMode?: "list" | "cards";
}
// Group posts by year
@@ -30,12 +33,52 @@ function groupByYear(posts: Post[]): Record<string, Post[]> {
);
}
export default function PostList({ posts }: PostListProps) {
export default function PostList({ posts, viewMode = "list" }: PostListProps) {
// Sort posts by date descending
const sortedPosts = [...posts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Card view: render all posts in a grid
if (viewMode === "cards") {
return (
<div className="post-cards">
{sortedPosts.map((post) => (
<Link key={post._id} to={`/${post.slug}`} className="post-card">
{/* Thumbnail image displayed as square using object-fit: cover */}
{post.image && (
<div className="post-card-image-wrapper">
<img
src={post.image}
alt={post.title}
className="post-card-image"
loading="lazy"
/>
</div>
)}
<div className="post-card-content">
<h3 className="post-card-title">{post.title}</h3>
{(post.excerpt || post.description) && (
<p className="post-card-excerpt">
{post.excerpt || post.description}
</p>
)}
<div className="post-card-meta">
{post.readTime && (
<span className="post-card-read-time">{post.readTime}</span>
)}
<span className="post-card-date">
{format(parseISO(post.date), "MMMM d, yyyy")}
</span>
</div>
</div>
</Link>
))}
</div>
);
}
// List view: group by year
const groupedPosts = groupByYear(sortedPosts);
const years = Object.keys(groupedPosts).sort((a, b) => Number(b) - Number(a));

View File

@@ -28,6 +28,8 @@ export interface BlogPageConfig {
title: string; // Page title for the blog page
description?: string; // Optional description shown on blog page
order?: number; // Nav order (lower = first, matches page frontmatter order)
viewMode: "list" | "cards"; // Default view mode (list or cards)
showViewToggle: boolean; // Show toggle button to switch between views
}
// Posts display configuration
@@ -150,6 +152,8 @@ 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"
showViewToggle: true, // Show toggle button to switch between list and card views
},
// Posts display configuration

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
@@ -5,8 +6,11 @@ import PostList from "../components/PostList";
import siteConfig from "../config/siteConfig";
import { ArrowLeft } from "lucide-react";
// Local storage key for blog view mode preference
const BLOG_VIEW_MODE_KEY = "blog-view-mode";
// Blog page component
// Displays all published posts in a year-grouped list
// Displays all published posts in a year-grouped list or card grid
// Controlled by siteConfig.blogPage and siteConfig.postsDisplay settings
export default function Blog() {
const navigate = useNavigate();
@@ -14,6 +18,26 @@ export default function Blog() {
// Fetch published posts from Convex
const posts = useQuery(api.posts.getAllPosts);
// State for view mode toggle (list or cards)
const [viewMode, setViewMode] = useState<"list" | "cards">(
siteConfig.blogPage.viewMode,
);
// Load saved view mode preference from localStorage
useEffect(() => {
const saved = localStorage.getItem(BLOG_VIEW_MODE_KEY);
if (saved === "list" || saved === "cards") {
setViewMode(saved);
}
}, []);
// Toggle view mode and save preference
const toggleViewMode = () => {
const newMode = viewMode === "list" ? "cards" : "list";
setViewMode(newMode);
localStorage.setItem(BLOG_VIEW_MODE_KEY, newMode);
};
// Check if posts should be shown on blog page
const showPosts = siteConfig.postsDisplay.showOnBlogPage;
@@ -29,10 +53,63 @@ export default function Blog() {
{/* Blog page header */}
<header className="blog-header">
<div className="blog-header-top">
<div>
<h1 className="blog-title">{siteConfig.blogPage.title}</h1>
{siteConfig.blogPage.description && (
<p className="blog-description">{siteConfig.blogPage.description}</p>
<p className="blog-description">
{siteConfig.blogPage.description}
</p>
)}
</div>
{/* View toggle button */}
{showPosts &&
siteConfig.blogPage.showViewToggle &&
posts !== undefined &&
posts.length > 0 && (
<button
className="view-toggle-button"
onClick={toggleViewMode}
aria-label={`Switch to ${viewMode === "list" ? "card" : "list"} view`}
>
{viewMode === "list" ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
)}
</button>
)}
</div>
</header>
{/* Blog posts section */}
@@ -41,7 +118,7 @@ export default function Blog() {
{posts === undefined ? null : posts.length === 0 ? (
<p className="no-posts">No posts yet. Check back soon!</p>
) : (
<PostList posts={posts} />
<PostList posts={posts} viewMode={viewMode} />
)}
</section>
)}

View File

@@ -3,6 +3,8 @@ import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import BlogPost from "../components/BlogPost";
import CopyPageDropdown from "../components/CopyPageDropdown";
import PageSidebar from "../components/PageSidebar";
import { extractHeadings } from "../utils/extractHeadings";
import { format, parseISO } from "date-fns";
import { ArrowLeft, Link as LinkIcon, Twitter, Rss } from "lucide-react";
import { useState, useEffect } from "react";
@@ -143,14 +145,18 @@ export default function Post() {
// If it's a static page, render simplified view
if (page) {
// Extract headings for sidebar TOC (only for pages with layout: "sidebar")
const headings = page.layout === "sidebar" ? extractHeadings(page.content) : [];
const hasSidebar = headings.length > 0;
return (
<div className="post-page">
<nav className="post-nav">
<div className={`post-page ${hasSidebar ? "post-page-with-sidebar" : ""}`}>
<nav className={`post-nav ${hasSidebar ? "post-nav-with-sidebar" : ""}`}>
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
{/* Copy page dropdown for static pages */}
{/* CopyPageDropdown in nav */}
<CopyPageDropdown
title={page.title}
content={page.content}
@@ -160,30 +166,40 @@ export default function Post() {
/>
</nav>
<article className="post-article">
<header className="post-header">
<h1 className="post-title">{page.title}</h1>
{/* Author avatar and name for pages (optional) */}
{(page.authorImage || page.authorName) && (
<div className="post-meta-header">
<div className="post-author">
{page.authorImage && (
<img
src={page.authorImage}
alt={page.authorName || "Author"}
className="post-author-image"
/>
)}
{page.authorName && (
<span className="post-author-name">{page.authorName}</span>
)}
<div className={hasSidebar ? "post-content-with-sidebar" : ""}>
{/* Left sidebar - TOC */}
{hasSidebar && (
<aside className="post-sidebar-wrapper post-sidebar-left">
<PageSidebar headings={headings} activeId={location.hash.slice(1)} />
</aside>
)}
{/* Main content */}
<article className={`post-article ${hasSidebar ? "post-article-with-sidebar" : ""}`}>
<header className="post-header">
<h1 className="post-title">{page.title}</h1>
{/* Author avatar and name for pages (optional) */}
{(page.authorImage || page.authorName) && (
<div className="post-meta-header">
<div className="post-author">
{page.authorImage && (
<img
src={page.authorImage}
alt={page.authorName || "Author"}
className="post-author-image"
/>
)}
{page.authorName && (
<span className="post-author-name">{page.authorName}</span>
)}
</div>
</div>
</div>
)}
</header>
)}
</header>
<BlogPost content={page.content} />
</article>
<BlogPost content={page.content} />
</article>
</div>
</div>
);
}

View File

@@ -51,6 +51,7 @@ const PAGE_FIELDS = [
{ name: "featuredOrder", required: false, example: "1" },
{ name: "authorName", required: false, example: '"Jane Doe"' },
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
{ name: "layout", required: false, example: '"sidebar"' },
];
// Generate frontmatter template based on content type
@@ -94,6 +95,7 @@ title: "Page Title"
slug: "page-url"
published: true
order: 1
layout: "sidebar"
---
# Page Title
@@ -103,6 +105,10 @@ Your page content goes here...
## Section
Add your markdown content.
## Another Section
With sidebar layout enabled, headings automatically appear in the table of contents.
`;
}

View File

@@ -579,6 +579,16 @@ body {
padding-top: 20px;
}
/* Full-width sidebar layout - breaks out of .main-content constraints */
.post-page-with-sidebar {
padding-top: 20px;
/* Break out of the 680px max-width container */
width: calc(100vw - 48px);
max-width: 1400px;
margin-left: calc(-1 * (min(100vw - 48px, 1400px) - 680px) / 2);
position: relative;
}
.post-nav {
display: flex;
justify-content: space-between;
@@ -586,6 +596,12 @@ body {
margin-bottom: 40px;
}
/* Shift CopyPageDropdown 7px to the left for sidebar layouts */
.post-nav-with-sidebar .copy-page-dropdown {
margin-right: 35px;
margin-top: 5px;
}
/* Copy Page Dropdown Styles */
.copy-page-dropdown {
position: relative;
@@ -743,6 +759,142 @@ body {
margin-bottom: 48px;
}
/* Sidebar layout for pages - two-column grid */
.post-content-with-sidebar {
display: grid;
grid-template-columns: 220px 1fr;
gap: 48px;
align-items: flex-start;
width: 100%;
}
.post-sidebar-wrapper {
position: sticky;
top: 100px;
align-self: flex-start;
max-height: calc(100vh - 120px);
overflow-y: auto;
}
/* Left sidebar - flush left */
.post-sidebar-left {
padding-right: 16px;
}
/* Content area - flexible width */
.post-article-with-sidebar {
min-width: 0; /* Prevent overflow */
max-width: 800px;
}
.page-sidebar {
padding-right: 16px;
}
.page-sidebar-list {
list-style: none;
padding: 0;
margin: 0;
}
.page-sidebar-item {
margin: 0;
padding: 0;
}
.page-sidebar-link {
display: block;
padding: 6px 12px;
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
line-height: 1.5;
border-radius: 4px;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.page-sidebar-link:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
.page-sidebar-item.active .page-sidebar-link {
color: var(--text-primary);
background-color: var(--bg-hover);
font-weight: 500;
}
/* Indentation for nested headings */
.page-sidebar-item-level-1 .page-sidebar-link {
padding-left: 12px;
font-weight: 500;
}
.page-sidebar-item-level-2 .page-sidebar-link {
padding-left: 24px;
}
.page-sidebar-item-level-3 .page-sidebar-link {
padding-left: 36px;
font-size: var(--font-size-xs);
}
/* Mobile: stack columns */
@media (max-width: 1024px) {
.post-page-with-sidebar {
width: 100%;
max-width: 100%;
margin-left: 0;
}
.post-content-with-sidebar {
grid-template-columns: 1fr;
gap: 24px;
}
.post-sidebar-left {
position: static;
width: 100%;
max-height: none;
padding-right: 0;
border-bottom: 1px solid var(--border-color);
padding-bottom: 16px;
}
.post-article-with-sidebar {
max-width: 100%;
}
.page-sidebar {
padding-right: 0;
}
.page-sidebar-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.page-sidebar-item {
display: inline-block;
}
.page-sidebar-link {
padding: 4px 8px;
font-size: var(--font-size-xs);
white-space: nowrap;
}
.page-sidebar-item-level-1 .page-sidebar-link,
.page-sidebar-item-level-2 .page-sidebar-link,
.page-sidebar-item-level-3 .page-sidebar-link {
padding-left: 8px;
padding-right: 8px;
}
}
/* Post page article title */
.post-header .post-title {
font-size: var(--font-size-post-title);
@@ -884,6 +1036,69 @@ body {
margin: 48px 0;
}
/* Collapsible sections (details/summary) */
.blog-post-content details {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0;
margin: 24px 0;
background-color: var(--bg-secondary);
}
.blog-post-content details summary {
cursor: pointer;
font-weight: 600;
padding: 12px 16px;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.blog-post-content details summary::-webkit-details-marker {
display: none;
}
.blog-post-content details summary::before {
content: "";
width: 0;
height: 0;
border-left: 6px solid var(--text-secondary);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.2s ease;
}
.blog-post-content details[open] summary::before {
transform: rotate(90deg);
}
.blog-post-content details summary:hover {
background-color: var(--bg-primary);
}
.blog-post-content details[open] summary {
border-bottom: 1px solid var(--border-color);
}
.blog-post-content details > *:not(summary) {
padding: 0 16px;
}
.blog-post-content details > p:first-of-type {
margin-top: 16px;
}
.blog-post-content details > *:last-child {
margin-bottom: 16px;
}
/* Nested details styling */
.blog-post-content details details {
margin: 12px 0;
}
/* Table styles - GitHub-style tables */
.blog-table-wrapper {
overflow-x: auto;
@@ -1145,6 +1360,13 @@ body {
margin-bottom: 40px;
}
.blog-header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.blog-title {
font-size: var(--font-size-blog-page-title);
font-weight: 600;
@@ -1163,6 +1385,101 @@ body {
margin-top: 20px;
}
/* Blog post cards grid (thumbnail view) */
.post-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-top: 8px;
}
.post-card {
display: flex;
flex-direction: column;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
text-decoration: none;
transition: all 0.15s ease;
overflow: hidden;
}
.post-card:hover {
background-color: var(--bg-hover);
border-color: var(--text-muted);
}
/* Thumbnail image wrapper with square aspect ratio */
.post-card-image-wrapper {
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
flex-shrink: 0;
}
/* Image displays as square regardless of original aspect ratio */
.post-card-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
transition: transform 0.2s ease;
}
.post-card:hover .post-card-image {
transform: scale(1.03);
}
/* Content wrapper for text below image */
.post-card-content {
padding: 16px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
/* Cards without images get padding directly */
.post-card:not(:has(.post-card-image-wrapper)) {
padding: 20px;
}
.post-card-title {
font-size: var(--font-size-featured-card-title);
font-weight: 500;
color: var(--text-primary);
margin: 0 0 8px 0;
line-height: 1.4;
}
.post-card-excerpt {
font-size: var(--font-size-featured-card-excerpt);
color: var(--text-secondary);
margin: 0 0 12px 0;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex-grow: 1;
}
.post-card-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: var(--font-size-post-meta);
color: var(--text-muted);
margin-top: auto;
}
.post-card-read-time {
color: var(--text-muted);
}
.post-card-date {
color: var(--text-muted);
}
.blog-disabled-message {
color: var(--text-muted);
font-size: 0.95rem;
@@ -1571,7 +1888,8 @@ body {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
/* Card content */
@@ -2800,7 +3118,8 @@ body {
}
@keyframes badge-pulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {
@@ -2936,6 +3255,15 @@ body {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Dark theme adjustments for post cards */
:root[data-theme="dark"] .post-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
:root[data-theme="dark"] .post-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
:root[data-theme="dark"] .logo-marquee-image {
filter: grayscale(100%) invert(1);
opacity: 0.5;
@@ -2988,6 +3316,14 @@ body {
box-shadow: 0 4px 12px rgba(139, 115, 85, 0.12);
}
:root[data-theme="tan"] .post-card {
box-shadow: 0 2px 8px rgba(139, 115, 85, 0.08);
}
:root[data-theme="tan"] .post-card:hover {
box-shadow: 0 4px 12px rgba(139, 115, 85, 0.12);
}
/* Cloud theme adjustments for featured cards */
:root[data-theme="cloud"] .featured-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
@@ -2997,6 +3333,14 @@ body {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:root[data-theme="cloud"] .post-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
:root[data-theme="cloud"] .post-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Featured cards responsive */
@media (max-width: 768px) {
.featured-cards {
@@ -3029,6 +3373,34 @@ body {
height: 28px;
max-width: 100px;
}
/* Blog post cards responsive */
.post-cards {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.post-card:not(:has(.post-card-image-wrapper)) {
padding: 16px;
}
.post-card-content {
padding: 12px;
}
.post-card-title {
font-size: var(--font-size-search-hint);
}
.post-card-excerpt {
font-size: var(--font-size-sm);
-webkit-line-clamp: 2;
}
.blog-header-top {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 480px) {
@@ -3041,6 +3413,14 @@ body {
aspect-ratio: 16 / 9;
}
.post-cards {
grid-template-columns: 1fr;
}
.post-card-image-wrapper {
aspect-ratio: 16 / 9;
}
.view-toggle-button {
width: 32px;
height: 32px;

View File

@@ -0,0 +1,33 @@
export interface Heading {
level: number; // 1, 2, or 3
text: string;
id: string;
}
// Generate slug from heading text for anchor links
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim();
}
// Extract headings from markdown content
export function extractHeadings(content: string): Heading[] {
const headingRegex = /^(#{1,3})\s+(.+)$/gm;
const headings: Heading[] = [];
let match;
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length;
const text = match[2].trim();
const id = generateSlug(text);
headings.push({ level, text, id });
}
return headings;
}