mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
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:
20
TASK.md
20
TASK.md
@@ -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.)
|
||||
|
||||
80
changelog.md
80
changelog.md
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
113
content/blog/netlify-edge-excludedpath-ai-crawlers.md
Normal file
113
content/blog/netlify-edge-excludedpath-ai-crawlers.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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"])
|
||||
|
||||
6
files.md
6
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 |
|
||||
| `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
198
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2025-12-22
|
||||
Date: 2025-12-23
|
||||
---
|
||||
|
||||
You found the contact page. Nice
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
113
public/raw/netlify-edge-excludedpath-ai-crawlers.md
Normal file
113
public/raw/netlify-edge-excludedpath-ai-crawlers.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || "");
|
||||
|
||||
68
src/components/PageSidebar.tsx
Normal file
68
src/components/PageSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
33
src/utils/extractHeadings.ts
Normal file
33
src/utils/extractHeadings.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user