mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
feat: add featured section, logo gallery, Firecrawl import, and API export
Featured Section - Frontmatter-controlled featured items with featured: true and featuredOrder - Card view with excerpts and list/card toggle button - View preference saved to localStorage - New Convex queries for featured posts and pages with by_featured index Logo Gallery - Continuous marquee scroll with clickable logos - CSS animation, grayscale with color on hover - Configurable speed, position, and title - 5 sample logos included Firecrawl Content Importer - npm run import <url> scrapes external URLs to markdown drafts - Creates local files in content/blog/ with frontmatter - Then sync to dev or prod (no separate import:prod command) API Enhancements - New /api/export endpoint for batch content fetching - AI plugin discovery at /.well-known/ai-plugin.json - OpenAPI 3.0 spec at /openapi.yaml - Enhanced llms.txt documentation Documentation - AGENTS.md with codebase instructions for AI agents - Updated all sync vs deploy tables to include import workflow - Renamed content/pages/changelog.md to changelog-page.md Technical - New components: FeaturedCards.tsx, LogoMarquee.tsx - New script: scripts/import-url.ts - New dependency: @mendable/firecrawl-js - Schema updates with featured, featuredOrder, excerpt fields
This commit is contained in:
397
AGENTS.md
Normal file
397
AGENTS.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# AGENTS.md
|
||||
|
||||
Instructions for AI coding agents working on this codebase.
|
||||
|
||||
## Project overview
|
||||
|
||||
A real-time markdown blog powered by Convex and React. Content syncs instantly without rebuilds. Write markdown, run a sync command, and posts appear immediately across all connected browsers.
|
||||
|
||||
**Key features:**
|
||||
- Markdown posts with frontmatter
|
||||
- Four themes (dark, light, tan, cloud)
|
||||
- Full text search with Command+K
|
||||
- Real-time analytics at `/stats`
|
||||
- RSS feeds and sitemap for SEO
|
||||
- API endpoints for AI/LLM access
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Frontend | React 18, TypeScript, Vite |
|
||||
| Backend | Convex (real-time serverless database) |
|
||||
| Styling | CSS variables, no preprocessor |
|
||||
| Hosting | Netlify with edge functions |
|
||||
| Content | Markdown with gray-matter frontmatter |
|
||||
|
||||
## Setup commands
|
||||
|
||||
```bash
|
||||
npm install # Install dependencies
|
||||
npx convex dev # Initialize Convex (creates .env.local)
|
||||
npm run dev # Start dev server at http://localhost:5173
|
||||
```
|
||||
|
||||
## Content sync commands
|
||||
|
||||
```bash
|
||||
npm run sync # Sync markdown to development Convex
|
||||
npm run sync:prod # Sync markdown to production Convex
|
||||
npm run import <url> # Import external URL as markdown post
|
||||
```
|
||||
|
||||
Content syncs instantly. No rebuild needed for markdown changes.
|
||||
|
||||
## Build and deploy
|
||||
|
||||
```bash
|
||||
npm run build # Build for production
|
||||
npx convex deploy # Deploy Convex functions to production
|
||||
```
|
||||
|
||||
**Netlify build command:**
|
||||
```bash
|
||||
npm ci --include=dev && npx convex deploy --cmd 'npm run build'
|
||||
```
|
||||
|
||||
## Code style guidelines
|
||||
|
||||
- Use TypeScript strict mode
|
||||
- Prefer functional components with hooks
|
||||
- Use Convex validators for all function arguments and returns
|
||||
- Always return `v.null()` when functions don't return values
|
||||
- Use CSS variables for theming (no hardcoded colors)
|
||||
- No emoji in UI or documentation
|
||||
- No em dashes between words
|
||||
- Sentence case for headings
|
||||
|
||||
## Convex patterns (read this)
|
||||
|
||||
### Always use validators
|
||||
|
||||
Every Convex function needs argument and return validators:
|
||||
|
||||
```typescript
|
||||
export const myQuery = query({
|
||||
args: { slug: v.string() },
|
||||
returns: v.union(v.object({...}), v.null()),
|
||||
handler: async (ctx, args) => {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Always use indexes
|
||||
|
||||
Never use `.filter()` on queries. Define indexes in schema and use `.withIndex()`:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
const post = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||
.first();
|
||||
|
||||
// Bad - causes table scans
|
||||
const post = await ctx.db
|
||||
.query("posts")
|
||||
.filter((q) => q.eq(q.field("slug"), args.slug))
|
||||
.first();
|
||||
```
|
||||
|
||||
### Make mutations idempotent
|
||||
|
||||
Mutations should be safe to call multiple times:
|
||||
|
||||
```typescript
|
||||
export const heartbeat = mutation({
|
||||
args: { sessionId: v.string(), currentPath: v.string() },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query("activeSessions")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
// Early return if recently updated with same data
|
||||
if (existing.currentPath === args.currentPath &&
|
||||
now - existing.lastSeen < 10000) {
|
||||
return null;
|
||||
}
|
||||
await ctx.db.patch(existing._id, { currentPath: args.currentPath, lastSeen: now });
|
||||
return null;
|
||||
}
|
||||
|
||||
await ctx.db.insert("activeSessions", { ...args, lastSeen: now });
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Patch directly without reading
|
||||
|
||||
When you only need to update fields, patch directly:
|
||||
|
||||
```typescript
|
||||
// Good - patch directly
|
||||
await ctx.db.patch(args.id, { content: args.content });
|
||||
|
||||
// Bad - unnecessary read creates conflict window
|
||||
const doc = await ctx.db.get(args.id);
|
||||
if (!doc) throw new Error("Not found");
|
||||
await ctx.db.patch(args.id, { content: args.content });
|
||||
```
|
||||
|
||||
### Use event records for counters
|
||||
|
||||
Never increment counters on documents. Use separate event records:
|
||||
|
||||
```typescript
|
||||
// Good - insert event record
|
||||
await ctx.db.insert("pageViews", { path, sessionId, timestamp: Date.now() });
|
||||
|
||||
// Bad - counter updates cause write conflicts
|
||||
await ctx.db.patch(pageId, { views: page.views + 1 });
|
||||
```
|
||||
|
||||
### Frontend debouncing
|
||||
|
||||
Debounce rapid mutations from the frontend. Use refs to prevent duplicate calls:
|
||||
|
||||
```typescript
|
||||
const isHeartbeatPending = useRef(false);
|
||||
const lastHeartbeatTime = useRef(0);
|
||||
|
||||
const sendHeartbeat = useCallback(async (path: string) => {
|
||||
if (isHeartbeatPending.current) return;
|
||||
if (Date.now() - lastHeartbeatTime.current < 5000) return;
|
||||
|
||||
isHeartbeatPending.current = true;
|
||||
lastHeartbeatTime.current = Date.now();
|
||||
|
||||
try {
|
||||
await heartbeatMutation({ sessionId, currentPath: path });
|
||||
} finally {
|
||||
isHeartbeatPending.current = false;
|
||||
}
|
||||
}, [heartbeatMutation]);
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
markdown-blog/
|
||||
├── content/
|
||||
│ ├── blog/ # Markdown blog posts
|
||||
│ └── pages/ # Static pages (About, Docs, etc.)
|
||||
├── convex/
|
||||
│ ├── schema.ts # Database schema with indexes
|
||||
│ ├── posts.ts # Post queries and mutations
|
||||
│ ├── pages.ts # Page queries and mutations
|
||||
│ ├── stats.ts # Analytics (conflict-free patterns)
|
||||
│ ├── search.ts # Full text search
|
||||
│ ├── http.ts # HTTP endpoints (sitemap, API)
|
||||
│ ├── rss.ts # RSS feed generation
|
||||
│ └── crons.ts # Scheduled cleanup jobs
|
||||
├── netlify/
|
||||
│ └── edge-functions/ # Proxies for RSS, sitemap, API
|
||||
├── public/
|
||||
│ ├── images/ # Static images and logos
|
||||
│ ├── robots.txt # Crawler rules
|
||||
│ └── llms.txt # AI agent discovery
|
||||
├── scripts/
|
||||
│ └── sync-posts.ts # Markdown to Convex sync
|
||||
└── src/
|
||||
├── components/ # React components
|
||||
├── context/ # Theme context
|
||||
├── hooks/ # Custom hooks (usePageTracking)
|
||||
├── pages/ # Route components
|
||||
└── styles/ # Global CSS with theme variables
|
||||
```
|
||||
|
||||
## Frontmatter fields
|
||||
|
||||
### Blog posts (content/blog/)
|
||||
|
||||
| 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 |
|
||||
| featured | No | true for featured section |
|
||||
| featuredOrder | No | Display order (lower first) |
|
||||
| excerpt | No | Short text for card view |
|
||||
| image | No | OG image path |
|
||||
|
||||
### Static pages (content/pages/)
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| title | Yes | Page title |
|
||||
| slug | Yes | URL path |
|
||||
| published | Yes | true to show |
|
||||
| order | No | Nav order (lower first) |
|
||||
| featured | No | true for featured section |
|
||||
| featuredOrder | No | Display order (lower first) |
|
||||
|
||||
## Database schema
|
||||
|
||||
Key tables and their indexes:
|
||||
|
||||
```typescript
|
||||
posts: defineTable({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
description: v.string(),
|
||||
content: v.string(),
|
||||
date: v.string(),
|
||||
published: v.boolean(),
|
||||
tags: v.array(v.string()),
|
||||
// ... optional fields
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_published", ["published"])
|
||||
.index("by_featured", ["featured"])
|
||||
.searchIndex("search_title", { searchField: "title" })
|
||||
.searchIndex("search_content", { searchField: "content" })
|
||||
|
||||
pages: defineTable({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
content: v.string(),
|
||||
published: v.boolean(),
|
||||
// ... optional fields
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_published", ["published"])
|
||||
.index("by_featured", ["featured"])
|
||||
|
||||
pageViews: defineTable({
|
||||
path: v.string(),
|
||||
pageType: v.string(),
|
||||
sessionId: v.string(),
|
||||
timestamp: v.number(),
|
||||
})
|
||||
.index("by_path", ["path"])
|
||||
.index("by_timestamp", ["timestamp"])
|
||||
.index("by_session_path", ["sessionId", "path"])
|
||||
|
||||
activeSessions: defineTable({
|
||||
sessionId: v.string(),
|
||||
currentPath: v.string(),
|
||||
lastSeen: v.number(),
|
||||
})
|
||||
.index("by_sessionId", ["sessionId"])
|
||||
.index("by_lastSeen", ["lastSeen"])
|
||||
```
|
||||
|
||||
## HTTP endpoints
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| /rss.xml | RSS feed with descriptions |
|
||||
| /rss-full.xml | Full content RSS for LLMs |
|
||||
| /sitemap.xml | Dynamic XML sitemap |
|
||||
| /api/posts | JSON list of all posts |
|
||||
| /api/post?slug=xxx | Single post JSON or markdown |
|
||||
| /api/export | Batch export all posts with content |
|
||||
| /stats | Real-time analytics page |
|
||||
| /.well-known/ai-plugin.json | AI plugin manifest |
|
||||
| /openapi.yaml | OpenAPI 3.0 specification |
|
||||
| /llms.txt | AI agent discovery |
|
||||
|
||||
## Content import
|
||||
|
||||
Import external URLs as markdown posts using Firecrawl:
|
||||
|
||||
```bash
|
||||
npm run import https://example.com/article
|
||||
```
|
||||
|
||||
Requires `FIRECRAWL_API_KEY` in `.env.local`. Get a key from firecrawl.dev.
|
||||
|
||||
## Environment files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| .env.local | Development Convex URL (auto-created by `npx convex dev`) |
|
||||
| .env.production.local | Production Convex URL (create manually) |
|
||||
|
||||
Both are gitignored.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- Escape HTML in all HTTP endpoint outputs using `escapeHtml()`
|
||||
- Escape XML in RSS feeds using `escapeXml()` or CDATA
|
||||
- Use indexed queries, never scan full tables
|
||||
- External links must use `rel="noopener noreferrer"`
|
||||
- No console statements in production code
|
||||
- Validate frontmatter before syncing content
|
||||
|
||||
## Testing
|
||||
|
||||
No automated test suite. Manual testing:
|
||||
|
||||
1. Run `npm run sync` after content changes
|
||||
2. Verify content appears at http://localhost:5173
|
||||
3. Check Convex dashboard for function errors
|
||||
4. Test search with Command+K
|
||||
5. Verify stats page updates in real-time
|
||||
|
||||
## Write conflict prevention
|
||||
|
||||
This codebase implements specific patterns to avoid Convex write conflicts:
|
||||
|
||||
**Backend (convex/stats.ts):**
|
||||
- 10-second dedup window for heartbeats
|
||||
- Early return when session was recently updated
|
||||
- Indexed queries for efficient lookups
|
||||
|
||||
**Frontend (src/hooks/usePageTracking.ts):**
|
||||
- 5-second debounce window using refs
|
||||
- Pending state tracking prevents overlapping calls
|
||||
- Path tracking skips redundant heartbeats
|
||||
|
||||
See `prds/howtoavoidwriteconflicts.md` for full details.
|
||||
|
||||
## Configuration
|
||||
|
||||
Site config lives in `src/pages/Home.tsx`:
|
||||
|
||||
```typescript
|
||||
const siteConfig = {
|
||||
name: "Site Name",
|
||||
title: "Tagline",
|
||||
logo: "/images/logo.svg", // null to hide
|
||||
featuredViewMode: "list", // 'list' or 'cards'
|
||||
showViewToggle: true,
|
||||
logoGallery: {
|
||||
enabled: true,
|
||||
images: [{ src: "/images/logos/logo.svg", href: "https://..." }],
|
||||
position: "above-footer",
|
||||
speed: 30,
|
||||
title: "Trusted by",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Theme default in `src/context/ThemeContext.tsx`:
|
||||
|
||||
```typescript
|
||||
const DEFAULT_THEME: Theme = "tan"; // dark, light, tan, cloud
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/)
|
||||
- [Convex Write Conflicts](https://docs.convex.dev/error#1)
|
||||
- [Convex TypeScript](https://docs.convex.dev/understanding/best-practices/typescript)
|
||||
- [Project README](./README.md)
|
||||
- [Changelog](./changelog.md)
|
||||
- [Files Reference](./files.md)
|
||||
|
||||
165
README.md
165
README.md
@@ -13,6 +13,8 @@ A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO
|
||||
- Fully responsive design
|
||||
- Real-time analytics at `/stats`
|
||||
- Full text search with Command+K shortcut
|
||||
- Featured section with list/card view toggle
|
||||
- Logo gallery with continuous marquee scroll
|
||||
|
||||
### SEO and Discovery
|
||||
|
||||
@@ -27,9 +29,18 @@ A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO
|
||||
|
||||
- `/api/posts` - JSON list of all posts for agents
|
||||
- `/api/post?slug=xxx` - Single post JSON or markdown
|
||||
- `/api/export` - Batch export all posts with full content
|
||||
- `/rss-full.xml` - Full content RSS for LLM ingestion
|
||||
- `/.well-known/ai-plugin.json` - AI plugin manifest
|
||||
- `/openapi.yaml` - OpenAPI 3.0 specification
|
||||
- Copy Page dropdown for sharing to ChatGPT, Claude
|
||||
|
||||
### Content Import
|
||||
|
||||
- Import external URLs as markdown posts using Firecrawl
|
||||
- Run `npm run import <url>` to scrape and create draft posts locally
|
||||
- Then sync to dev or prod with `npm run sync` or `npm run sync:prod`
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -92,6 +103,7 @@ published: true
|
||||
tags: ["tag1", "tag2"]
|
||||
readTime: "5 min read"
|
||||
image: "/images/my-header.png"
|
||||
excerpt: "Short text for featured cards"
|
||||
---
|
||||
|
||||
Your markdown content here...
|
||||
@@ -132,6 +144,85 @@ const siteConfig = {
|
||||
|
||||
Replace `public/images/logo.svg` with your own logo file.
|
||||
|
||||
## Featured Section
|
||||
|
||||
Posts and pages with `featured: true` in frontmatter appear in the featured section.
|
||||
|
||||
### Add to Featured
|
||||
|
||||
Add these fields to any post or page frontmatter:
|
||||
|
||||
```yaml
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
excerpt: "A short description for the card view."
|
||||
```
|
||||
|
||||
Then run `npm run sync`. No redeploy needed.
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `featured` | Set `true` to show in featured section |
|
||||
| `featuredOrder` | Order in featured section (lower = first) |
|
||||
| `excerpt` | Short description for card view |
|
||||
|
||||
### Display Modes
|
||||
|
||||
The featured section supports two display modes:
|
||||
|
||||
- **List view** (default): Bullet list of links
|
||||
- **Card view**: Grid of cards with title and excerpt
|
||||
|
||||
Users can toggle between views. To change the default:
|
||||
|
||||
```typescript
|
||||
const siteConfig = {
|
||||
featuredViewMode: "cards", // 'list' or 'cards'
|
||||
showViewToggle: true, // Allow users to switch views
|
||||
};
|
||||
```
|
||||
|
||||
## Logo Gallery
|
||||
|
||||
The homepage includes a scrolling logo gallery with sample logos. Configure in `siteConfig`:
|
||||
|
||||
### Disable the gallery
|
||||
|
||||
```typescript
|
||||
logoGallery: {
|
||||
enabled: false,
|
||||
// ...
|
||||
},
|
||||
```
|
||||
|
||||
### Replace with your own logos
|
||||
|
||||
1. Add logo images to `public/images/logos/` (SVG recommended)
|
||||
2. Update the images array with logos and links:
|
||||
|
||||
```typescript
|
||||
logoGallery: {
|
||||
enabled: true,
|
||||
images: [
|
||||
{ src: "/images/logos/your-logo-1.svg", href: "https://example.com" },
|
||||
{ src: "/images/logos/your-logo-2.svg", href: "https://anothersite.com" },
|
||||
],
|
||||
position: "above-footer", // or "below-featured"
|
||||
speed: 30, // Seconds for one scroll cycle
|
||||
title: "Trusted by", // Set to undefined to hide
|
||||
},
|
||||
```
|
||||
|
||||
Each logo object supports:
|
||||
- `src`: Path to the logo image (required)
|
||||
- `href`: URL to link to when clicked (optional)
|
||||
|
||||
### Remove sample logos
|
||||
|
||||
Delete sample files from `public/images/logos/` and replace the images array with your own logos, or set `enabled: false` to hide the gallery entirely.
|
||||
|
||||
The gallery uses CSS animations for smooth infinite scrolling. Logos appear grayscale and colorize on hover.
|
||||
|
||||
### Favicon
|
||||
|
||||
Replace `public/favicon.svg` with your own icon. The default is a rounded square with the letter "m". Edit the SVG to change the letter or style.
|
||||
@@ -244,15 +335,16 @@ markdown-site/
|
||||
|
||||
## Scripts Reference
|
||||
|
||||
| Script | Description |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| `npm run dev` | Start Vite dev server |
|
||||
| `npm run dev:convex` | Start Convex dev backend |
|
||||
| `npm run sync` | Sync posts to dev deployment |
|
||||
| `npm run sync:prod` | Sync posts to production deployment |
|
||||
| `npm run build` | Build for production |
|
||||
| `npm run deploy` | Sync + build (for manual deploys) |
|
||||
| `npm run deploy:prod` | Deploy Convex functions + sync to production |
|
||||
| Script | Description |
|
||||
| --------------------- | -------------------------------------------------- |
|
||||
| `npm run dev` | Start Vite dev server |
|
||||
| `npm run dev:convex` | Start Convex dev backend |
|
||||
| `npm run sync` | Sync posts to dev deployment |
|
||||
| `npm run sync:prod` | Sync posts to production deployment |
|
||||
| `npm run import` | Import URL as local markdown draft (then sync) |
|
||||
| `npm run build` | Build for production |
|
||||
| `npm run deploy` | Sync + build (for manual deploys) |
|
||||
| `npm run deploy:prod` | Deploy Convex functions + sync to production |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -301,16 +393,51 @@ How it works:
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
| ------------------------------ | ------------------------------- |
|
||||
| `/stats` | Real-time site analytics |
|
||||
| `/rss.xml` | RSS feed with post descriptions |
|
||||
| `/rss-full.xml` | RSS feed with full post content |
|
||||
| `/sitemap.xml` | Dynamic XML sitemap |
|
||||
| `/api/posts` | JSON list of all posts |
|
||||
| `/api/post?slug=xxx` | Single post as JSON |
|
||||
| `/api/post?slug=xxx&format=md` | Single post as markdown |
|
||||
| `/meta/post?slug=xxx` | Open Graph HTML for crawlers |
|
||||
| Endpoint | Description |
|
||||
| ------------------------------ | ------------------------------------ |
|
||||
| `/stats` | Real-time site analytics |
|
||||
| `/rss.xml` | RSS feed with post descriptions |
|
||||
| `/rss-full.xml` | RSS feed with full post content |
|
||||
| `/sitemap.xml` | Dynamic XML sitemap |
|
||||
| `/api/posts` | JSON list of all posts |
|
||||
| `/api/post?slug=xxx` | Single post as JSON |
|
||||
| `/api/post?slug=xxx&format=md` | Single post as markdown |
|
||||
| `/api/export` | Batch export all posts with content |
|
||||
| `/meta/post?slug=xxx` | Open Graph HTML for crawlers |
|
||||
| `/.well-known/ai-plugin.json` | AI plugin manifest |
|
||||
| `/openapi.yaml` | OpenAPI 3.0 specification |
|
||||
| `/llms.txt` | AI agent discovery |
|
||||
|
||||
## Import External Content
|
||||
|
||||
Use Firecrawl to import articles from external URLs as markdown posts:
|
||||
|
||||
```bash
|
||||
npm run import https://example.com/article
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Scrape the URL using Firecrawl API
|
||||
2. Convert to clean markdown
|
||||
3. Create a draft post in `content/blog/` locally
|
||||
4. Add frontmatter with title, description, and today's date
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Get an API key from [firecrawl.dev](https://firecrawl.dev)
|
||||
2. Add to `.env.local`:
|
||||
|
||||
```
|
||||
FIRECRAWL_API_KEY=fc-your-api-key
|
||||
```
|
||||
|
||||
**Why no `npm run import:prod`?** The import command only creates local markdown files. It does not interact with Convex. After importing, sync to your target environment:
|
||||
|
||||
- `npm run sync` for development
|
||||
- `npm run sync:prod` for production
|
||||
|
||||
Imported posts are created as drafts (`published: false`). Review, edit, set `published: true`, then sync.
|
||||
|
||||
## How Blog Post Slugs Work
|
||||
|
||||
|
||||
6
TASK.md
6
TASK.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## Current Status
|
||||
|
||||
v1.3.0 ready for deployment. Build passes. TypeScript verified.
|
||||
v1.5.0 ready for deployment. Build passes. TypeScript verified.
|
||||
|
||||
## Completed
|
||||
|
||||
@@ -36,6 +36,10 @@ v1.3.0 ready for deployment. Build passes. TypeScript verified.
|
||||
- [x] Real-time search with Command+K shortcut
|
||||
- [x] Search modal with keyboard navigation
|
||||
- [x] Full text search indexes for posts and pages
|
||||
- [x] Featured section with list/card view toggle
|
||||
- [x] Logo gallery with continuous marquee scroll
|
||||
- [x] Frontmatter-controlled featured items (featured, featuredOrder)
|
||||
- [x] Featured items sync with npm run sync (no redeploy needed)
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
|
||||
98
changelog.md
98
changelog.md
@@ -4,6 +4,104 @@ 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.6.1] - 2025-12-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Added Firecrawl import to all "When to sync vs deploy" tables in docs
|
||||
- Clarified import workflow: creates local files only, no `import:prod` needed
|
||||
- Updated README, setup-guide, how-to-publish, docs page, about-this-blog
|
||||
- Renamed `content/pages/changelog.md` to `changelog-page.md` to avoid confusion with root changelog
|
||||
|
||||
## [1.6.0] - 2025-12-18
|
||||
|
||||
### Added
|
||||
|
||||
- Firecrawl content importer for external URLs
|
||||
- New `npm run import <url>` command
|
||||
- Scrapes URLs and converts to local markdown drafts
|
||||
- Creates drafts in `content/blog/` with frontmatter
|
||||
- Uses Firecrawl API (requires `FIRECRAWL_API_KEY` in `.env.local`)
|
||||
- Then sync to dev (`npm run sync`) or prod (`npm run sync:prod`)
|
||||
- No separate `import:prod` command needed (import creates local files only)
|
||||
- New API endpoint `/api/export` for batch content fetching
|
||||
- Returns all posts with full markdown content
|
||||
- Single request for LLM ingestion
|
||||
- AI plugin discovery at `/.well-known/ai-plugin.json`
|
||||
- Standard format for AI tool integration
|
||||
- OpenAPI 3.0 specification at `/openapi.yaml`
|
||||
- Full API documentation
|
||||
- Describes all endpoints, parameters, and responses
|
||||
- Enhanced `llms.txt` with complete API documentation
|
||||
- Added all new endpoints
|
||||
- Improved quick start section
|
||||
- Added response schema documentation
|
||||
|
||||
### Technical
|
||||
|
||||
- New script: `scripts/import-url.ts`
|
||||
- New package dependency: `@mendable/firecrawl-js`
|
||||
- Updated `netlify/edge-functions/api.ts` for `/api/export` proxy
|
||||
- Updated `convex/http.ts` with export endpoint
|
||||
- Created `public/.well-known/` directory
|
||||
|
||||
## [1.5.0] - 2025-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- Frontmatter-controlled featured items
|
||||
- Add `featured: true` to any post or page frontmatter
|
||||
- Use `featuredOrder` to control display order (lower = first)
|
||||
- Featured items sync instantly with `npm run sync` (no redeploy needed)
|
||||
- New Convex queries for featured content
|
||||
- `getFeaturedPosts`: returns posts with `featured: true`
|
||||
- `getFeaturedPages`: returns pages with `featured: true`
|
||||
- Schema updates with `featured` and `featuredOrder` fields
|
||||
- Added `by_featured` index for efficient queries
|
||||
|
||||
### Changed
|
||||
|
||||
- Home.tsx now queries featured items from Convex instead of siteConfig
|
||||
- FeaturedCards component uses Convex queries for real-time updates
|
||||
- Removed hardcoded `featuredItems` and `featuredEssays` from siteConfig
|
||||
|
||||
### Technical
|
||||
|
||||
- Updated sync script to parse `featured` and `featuredOrder` from frontmatter
|
||||
- Added index on `featured` field in posts and pages tables
|
||||
- Both list and card views now use frontmatter data
|
||||
|
||||
## [1.4.0] - 2025-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- Featured section with list/card view toggle
|
||||
- Card view displays title and excerpt in a responsive grid
|
||||
- Toggle button in featured header to switch between views
|
||||
- View preference saved to localStorage
|
||||
- Logo gallery with continuous marquee scroll
|
||||
- Clickable logos with configurable URLs
|
||||
- CSS only animation for smooth infinite scrolling
|
||||
- Configurable speed, position, and title
|
||||
- Grayscale logos with color on hover
|
||||
- Responsive sizing across breakpoints
|
||||
- 5 sample logos included for easy customization
|
||||
- New `excerpt` field for posts and pages frontmatter
|
||||
- Used for card view descriptions
|
||||
- Falls back to description field for posts
|
||||
- Expanded `siteConfig` in Home.tsx
|
||||
- `featuredViewMode`: 'list' or 'cards'
|
||||
- `showViewToggle`: enable user toggle
|
||||
- `logoGallery`: full configuration object
|
||||
|
||||
### Technical
|
||||
|
||||
- New components: `FeaturedCards.tsx`, `LogoMarquee.tsx`
|
||||
- Updated schema with optional excerpt field
|
||||
- Updated sync script to parse excerpt from frontmatter
|
||||
- CSS uses theme variables for all four themes
|
||||
- Mobile responsive grid (3 to 2 to 1 columns for cards)
|
||||
|
||||
## [1.3.0] - 2025-12-17
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,6 +6,9 @@ slug: "about-this-blog"
|
||||
published: true
|
||||
tags: ["convex", "netlify", "open-source", "markdown"]
|
||||
readTime: "4 min read"
|
||||
featured: true
|
||||
featuredOrder: 3
|
||||
excerpt: "Learn how this open source site works with real-time sync and instant updates."
|
||||
---
|
||||
|
||||
# About This Markdown Site
|
||||
@@ -77,6 +80,8 @@ The setup takes about 10 minutes:
|
||||
|
||||
**Development vs Production:** Use `npm run sync` when testing locally against your dev Convex deployment. Use `npm run sync:prod` when deploying content to your live production site.
|
||||
|
||||
**Import external content:** Run `npm run import <url>` to scrape and create local markdown drafts. Then sync to dev or prod. There is no separate import command for production because import creates local files only.
|
||||
|
||||
Read the [setup guide](/setup-guide) for detailed steps.
|
||||
|
||||
## Customization
|
||||
|
||||
@@ -6,6 +6,9 @@ slug: "how-to-publish"
|
||||
published: true
|
||||
tags: ["tutorial", "markdown", "cursor", "publishing"]
|
||||
readTime: "3 min read"
|
||||
featured: true
|
||||
featuredOrder: 2
|
||||
excerpt: "Quick guide to writing and publishing markdown posts with npm run sync."
|
||||
---
|
||||
|
||||
# How to Publish a Blog Post
|
||||
@@ -38,16 +41,19 @@ readTime: "5 min read"
|
||||
---
|
||||
```
|
||||
|
||||
| Field | Required | What It Does |
|
||||
| ------------- | -------- | ----------------------------------- |
|
||||
| `title` | Yes | Displays as the post heading |
|
||||
| `description` | Yes | Shows in search results and sharing |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | Yes | Becomes the URL path |
|
||||
| `published` | Yes | Set `true` to show, `false` to hide |
|
||||
| `tags` | Yes | Topic labels for the post |
|
||||
| `readTime` | No | Estimated reading time |
|
||||
| `image` | No | Open Graph image for social sharing |
|
||||
| Field | Required | What It Does |
|
||||
| --------------- | -------- | --------------------------------------- |
|
||||
| `title` | Yes | Displays as the post heading |
|
||||
| `description` | Yes | Shows in search results and sharing |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | Yes | Becomes the URL path |
|
||||
| `published` | Yes | Set `true` to show, `false` to hide |
|
||||
| `tags` | Yes | Topic labels for the post |
|
||||
| `readTime` | No | Estimated reading time |
|
||||
| `image` | No | Open Graph image for social sharing |
|
||||
| `featured` | No | Set `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured section (lower first) |
|
||||
| `excerpt` | No | Short description for card view |
|
||||
|
||||
## Write Your Content
|
||||
|
||||
@@ -200,6 +206,88 @@ Your page content here...
|
||||
|
||||
The page will appear in the navigation. Use `order` to control the display sequence (lower numbers appear first).
|
||||
|
||||
## Sync vs Deploy
|
||||
|
||||
Not all changes use `npm run sync`. Here's when to sync vs redeploy:
|
||||
|
||||
| What you're changing | Command | Timing |
|
||||
| -------------------------------- | -------------------------- | -------------------- |
|
||||
| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) |
|
||||
| Import external URL | `npm run import` then sync | Instant (no rebuild) |
|
||||
| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild |
|
||||
| Logo gallery config | Redeploy | Requires rebuild |
|
||||
| React components/styles | Redeploy | Requires rebuild |
|
||||
|
||||
**Markdown content** syncs instantly via Convex. **Source code changes** (like siteConfig) require pushing to GitHub so Netlify rebuilds.
|
||||
|
||||
## Adding to Featured Section
|
||||
|
||||
To show a post or page in the homepage featured section, add these fields to frontmatter:
|
||||
|
||||
```yaml
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
excerpt: "A short description for the card view."
|
||||
```
|
||||
|
||||
Then run `npm run sync`. The item appears in the featured section instantly. No redeploy needed.
|
||||
|
||||
| Field | Description |
|
||||
| --------------- | ----------------------------------------- |
|
||||
| `featured` | Set `true` to show in featured section |
|
||||
| `featuredOrder` | Order in featured section (lower = first) |
|
||||
| `excerpt` | Short text shown on card view |
|
||||
|
||||
## Updating siteConfig
|
||||
|
||||
To change the logo gallery or site info, edit `src/pages/Home.tsx`:
|
||||
|
||||
```typescript
|
||||
const siteConfig = {
|
||||
name: "Your Site Name",
|
||||
title: "Your Tagline",
|
||||
|
||||
// Featured section display options
|
||||
featuredViewMode: "cards", // 'list' or 'cards'
|
||||
showViewToggle: true, // Let users switch between views
|
||||
|
||||
// Logo gallery
|
||||
logoGallery: {
|
||||
enabled: true,
|
||||
images: [
|
||||
{ src: "/images/logos/logo1.svg", href: "https://example.com" },
|
||||
{ src: "/images/logos/logo2.svg", href: "https://another.com" },
|
||||
],
|
||||
position: "above-footer",
|
||||
speed: 30,
|
||||
title: "Trusted by",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
After editing siteConfig, push to GitHub. Netlify will rebuild automatically.
|
||||
|
||||
## Import External Content
|
||||
|
||||
You can also import articles from external URLs using Firecrawl:
|
||||
|
||||
```bash
|
||||
npm run import https://example.com/article
|
||||
```
|
||||
|
||||
This creates a draft markdown file in `content/blog/` locally. It does not push to Convex directly.
|
||||
|
||||
**After importing:**
|
||||
|
||||
- Run `npm run sync` to push to development
|
||||
- Run `npm run sync:prod` to push to production
|
||||
|
||||
There is no `npm run import:prod` because the import step only creates local files. The sync step handles pushing to your target environment.
|
||||
|
||||
**Setup:** Add `FIRECRAWL_API_KEY=fc-xxx` to `.env.local`. Get a key from [firecrawl.dev](https://firecrawl.dev).
|
||||
|
||||
## Summary
|
||||
|
||||
Publishing is three steps:
|
||||
|
||||
92
content/blog/new-features-search-featured-logos.md
Normal file
92
content/blog/new-features-search-featured-logos.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: "New features: search, featured section, and logo gallery"
|
||||
description: "Three updates that make your markdown site more useful: Command+K search, frontmatter-controlled featured items, and a scrolling logo gallery."
|
||||
date: "2025-12-17"
|
||||
slug: "new-features-search-featured-logos"
|
||||
published: true
|
||||
tags: ["features", "search", "convex", "updates"]
|
||||
readTime: "4 min read"
|
||||
featured: true
|
||||
featuredOrder: 0
|
||||
excerpt: "Search your site with Command+K. Control featured items from frontmatter. Add a logo gallery."
|
||||
---
|
||||
|
||||
# New features: search, featured section, and logo gallery
|
||||
|
||||
Three updates shipped today. Each one makes your site more useful without adding complexity.
|
||||
|
||||
## Search with Command+K
|
||||
|
||||
Press Command+K (or Ctrl+K on Windows) to open search. Start typing. Results appear as you type.
|
||||
|
||||
The search finds matches in titles and content across all posts and pages. Title matches show first. Each result includes a snippet with context around the match.
|
||||
|
||||
Navigate with arrow keys. Press Enter to go. Press Escape to close.
|
||||
|
||||
Search uses Convex full text indexes. Results are reactive. If you publish a new post while the modal is open, it shows up in results immediately.
|
||||
|
||||
## Featured section from frontmatter
|
||||
|
||||
The homepage featured section now pulls from your markdown files. No more editing siteConfig to change what appears.
|
||||
|
||||
Add this to any post or page frontmatter:
|
||||
|
||||
```yaml
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
excerpt: "Short description for card view."
|
||||
```
|
||||
|
||||
Run `npm run sync`. The item appears in featured. No redeploy needed.
|
||||
|
||||
Lower numbers appear first. Posts and pages sort together. If two items have the same order, they sort alphabetically.
|
||||
|
||||
The toggle button lets visitors switch between list view and card view. Card view shows the excerpt. List view shows just titles.
|
||||
|
||||
## Logo gallery
|
||||
|
||||
A scrolling marquee of logos now sits above the footer. Good for showing partners, customers, or tools you use.
|
||||
|
||||
Configure it in siteConfig:
|
||||
|
||||
```typescript
|
||||
logoGallery: {
|
||||
enabled: true,
|
||||
images: [
|
||||
{ src: "/images/logos/logo1.svg", href: "https://example.com" },
|
||||
{ src: "/images/logos/logo2.svg" },
|
||||
],
|
||||
position: "above-footer",
|
||||
speed: 30,
|
||||
title: "Trusted by",
|
||||
},
|
||||
```
|
||||
|
||||
Each logo can link to a URL. Set `href` to make it clickable. Leave it out for a static logo.
|
||||
|
||||
The gallery uses CSS animations. No JavaScript. Logos display in grayscale and colorize on hover.
|
||||
|
||||
Five sample logos are included. Replace them with your own in `public/images/logos/`.
|
||||
|
||||
## What syncs vs what deploys
|
||||
|
||||
Quick reference:
|
||||
|
||||
| Change | Command | Speed |
|
||||
| ------------------- | -------------------------- | -------------- |
|
||||
| Blog posts | `npm run sync` | Instant |
|
||||
| Pages | `npm run sync` | Instant |
|
||||
| Featured items | `npm run sync` | Instant |
|
||||
| Import external URL | `npm run import` then sync | Instant |
|
||||
| Logo gallery config | Redeploy | Requires build |
|
||||
| siteConfig changes | Redeploy | Requires build |
|
||||
|
||||
Markdown content syncs instantly through Convex. Source code changes need a push to GitHub so Netlify rebuilds.
|
||||
|
||||
## Try it
|
||||
|
||||
1. Press Command+K right now. Search for "setup" or "publish".
|
||||
2. Check the featured section on the homepage. Toggle between views.
|
||||
3. Look at the logo gallery above the footer.
|
||||
|
||||
All three features work with every theme. Dark, light, tan, cloud.
|
||||
@@ -6,6 +6,9 @@ slug: "setup-guide"
|
||||
published: true
|
||||
tags: ["convex", "netlify", "tutorial", "deployment"]
|
||||
readTime: "8 min read"
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
excerpt: "Complete guide to fork, set up, and deploy your own markdown blog in under 10 minutes."
|
||||
---
|
||||
|
||||
# Fork and Deploy Your Own Markdown Blog
|
||||
@@ -37,18 +40,25 @@ This guide walks you through forking [this markdown site](https://github.com/way
|
||||
- [Adding Images](#adding-images)
|
||||
- [Sync After Adding Posts](#sync-after-adding-posts)
|
||||
- [Environment Files](#environment-files)
|
||||
- [When to Sync vs Deploy](#when-to-sync-vs-deploy)
|
||||
- [Customizing Your Blog](#customizing-your-blog)
|
||||
- [Change the Favicon](#change-the-favicon)
|
||||
- [Change the Site Logo](#change-the-site-logo)
|
||||
- [Change the Default Open Graph Image](#change-the-default-open-graph-image)
|
||||
- [Update Site Configuration](#update-site-configuration)
|
||||
- [Featured Section](#featured-section)
|
||||
- [Logo Gallery](#logo-gallery)
|
||||
- [Change the Default Theme](#change-the-default-theme)
|
||||
- [Change the Font](#change-the-font)
|
||||
- [Add Static Pages (Optional)](#add-static-pages-optional)
|
||||
- [Update SEO Meta Tags](#update-seo-meta-tags)
|
||||
- [Update llms.txt and robots.txt](#update-llmstxt-and-robotstxt)
|
||||
- [Search](#search)
|
||||
- [Using Search](#using-search)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Real-time Stats](#real-time-stats)
|
||||
- [API Endpoints](#api-endpoints)
|
||||
- [Import External Content](#import-external-content)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Posts not appearing](#posts-not-appearing)
|
||||
- [RSS/Sitemap not working](#rsssitemap-not-working)
|
||||
@@ -116,10 +126,30 @@ export default defineSchema({
|
||||
published: v.boolean(),
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
lastSyncedAt: v.optional(v.number()),
|
||||
image: v.optional(v.string()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_published", ["published"]),
|
||||
.index("by_published", ["published"])
|
||||
.index("by_featured", ["featured"]),
|
||||
|
||||
pages: defineTable({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
content: v.string(),
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_published", ["published"])
|
||||
.index("by_featured", ["featured"]),
|
||||
|
||||
viewCounts: defineTable({
|
||||
slug: v.string(),
|
||||
@@ -266,16 +296,19 @@ Your markdown content here...
|
||||
|
||||
### Frontmatter Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------- | -------- | ----------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | Short description for SEO |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | Yes | URL path (must be unique) |
|
||||
| `published` | Yes | Set to `true` to publish |
|
||||
| `tags` | Yes | Array of topic tags |
|
||||
| `readTime` | No | Estimated reading time |
|
||||
| `image` | No | Header/Open Graph image URL |
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | Short description for SEO |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | Yes | URL path (must be unique) |
|
||||
| `published` | Yes | Set to `true` to publish |
|
||||
| `tags` | Yes | Array of topic tags |
|
||||
| `readTime` | No | Estimated reading time |
|
||||
| `image` | No | Header/Open Graph image URL |
|
||||
| `excerpt` | No | Short excerpt for card view |
|
||||
| `featured` | No | Set `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured section (lower = first) |
|
||||
|
||||
### Adding Images
|
||||
|
||||
@@ -336,6 +369,22 @@ npm run sync:prod
|
||||
|
||||
Both files are gitignored. Each developer creates their own local environment files.
|
||||
|
||||
### When to Sync vs Deploy
|
||||
|
||||
| What you're changing | Command | Timing |
|
||||
| -------------------------------- | -------------------------- | -------------------- |
|
||||
| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) |
|
||||
| Import external URL | `npm run import` then sync | Instant (no rebuild) |
|
||||
| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild |
|
||||
| Logo gallery config | Redeploy | Requires rebuild |
|
||||
| React components/styles | Redeploy | Requires rebuild |
|
||||
|
||||
**Markdown content** syncs instantly via Convex. **Source code changes** require pushing to GitHub for Netlify to rebuild.
|
||||
|
||||
**Featured items** can now be controlled via markdown frontmatter. Add `featured: true` and `featuredOrder: 1` to any post or page, then run `npm run sync`.
|
||||
|
||||
## Customizing Your Blog
|
||||
|
||||
### Change the Favicon
|
||||
@@ -386,14 +435,114 @@ const siteConfig = {
|
||||
title: "Your Title",
|
||||
intro: "Your introduction...",
|
||||
bio: "Your bio...",
|
||||
|
||||
// Featured section options
|
||||
featuredViewMode: "list", // 'list' or 'cards'
|
||||
showViewToggle: true, // Let users switch between views
|
||||
featuredItems: [
|
||||
{ slug: "post-slug", type: "post" },
|
||||
{ slug: "page-slug", type: "page" },
|
||||
],
|
||||
featuredEssays: [{ title: "Post Title", slug: "post-slug" }],
|
||||
|
||||
// Logo gallery (marquee scroll with clickable links)
|
||||
logoGallery: {
|
||||
enabled: true, // Set false to hide
|
||||
images: [
|
||||
{ src: "/images/logos/logo1.svg", href: "https://example.com" },
|
||||
{ src: "/images/logos/logo2.svg", href: "https://another.com" },
|
||||
],
|
||||
position: "above-footer", // or 'below-featured'
|
||||
speed: 30, // Seconds for one scroll cycle
|
||||
title: "Trusted by",
|
||||
},
|
||||
|
||||
links: {
|
||||
github: "https://github.com/waynesutton/markdown-site",
|
||||
twitter: "https://twitter.com/yourusername",
|
||||
docs: "/setup-guide",
|
||||
convex: "https://convex.dev",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Featured Section
|
||||
|
||||
The homepage featured section shows posts and pages marked with `featured: true` in their frontmatter. It supports two display modes:
|
||||
|
||||
1. **List view** (default): Bullet list of links
|
||||
2. **Card view**: Grid of cards showing title and excerpt
|
||||
|
||||
**Add a post to featured section:**
|
||||
|
||||
Add these fields to any post or page frontmatter:
|
||||
|
||||
```yaml
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
excerpt: "A short description that appears on the card."
|
||||
```
|
||||
|
||||
Then run `npm run sync`. The post appears in the featured section instantly. No redeploy needed.
|
||||
|
||||
**Order featured items:**
|
||||
|
||||
Use `featuredOrder` to control display order. Lower numbers appear first. Posts and pages are sorted together.
|
||||
|
||||
**Toggle view mode:**
|
||||
|
||||
Users can toggle between list and card views using the icon button next to "Get started:". To change the default view, set `featuredViewMode: "cards"` in siteConfig.
|
||||
|
||||
### Logo Gallery
|
||||
|
||||
The homepage includes a scrolling logo gallery with 5 sample logos. Customize or disable it in siteConfig:
|
||||
|
||||
**Disable the gallery:**
|
||||
|
||||
```typescript
|
||||
logoGallery: {
|
||||
enabled: false, // Set to false to hide
|
||||
// ...
|
||||
},
|
||||
```
|
||||
|
||||
**Replace with your own logos:**
|
||||
|
||||
1. Add your logo images to `public/images/logos/` (SVG recommended)
|
||||
2. Update the images array with your logos and links:
|
||||
|
||||
```typescript
|
||||
logoGallery: {
|
||||
enabled: true,
|
||||
images: [
|
||||
{ src: "/images/logos/your-logo-1.svg", href: "https://example.com" },
|
||||
{ src: "/images/logos/your-logo-2.svg", href: "https://anothersite.com" },
|
||||
],
|
||||
position: "above-footer",
|
||||
speed: 30,
|
||||
title: "Trusted by",
|
||||
},
|
||||
```
|
||||
|
||||
Each logo object supports:
|
||||
|
||||
- `src`: Path to the logo image (required)
|
||||
- `href`: URL to link to when clicked (optional)
|
||||
|
||||
**Remove sample logos:**
|
||||
|
||||
Delete the sample files from `public/images/logos/` and clear the images array, or replace them with your own.
|
||||
|
||||
**Configuration options:**
|
||||
|
||||
| Option | Description |
|
||||
| ---------- | ---------------------------------------------------- |
|
||||
| `enabled` | `true` to show, `false` to hide |
|
||||
| `images` | Array of logo objects with `src` and optional `href` |
|
||||
| `position` | `'above-footer'` or `'below-featured'` |
|
||||
| `speed` | Seconds for one scroll cycle (lower = faster) |
|
||||
| `title` | Text above gallery (set to `undefined` to hide) |
|
||||
|
||||
The gallery uses CSS animations for smooth infinite scrolling. Logos display in grayscale and colorize on hover.
|
||||
|
||||
### Change the Default Theme
|
||||
|
||||
Edit `src/context/ThemeContext.tsx`:
|
||||
@@ -464,6 +613,29 @@ Edit `index.html` to update:
|
||||
|
||||
Edit `public/llms.txt` and `public/robots.txt` with your site information.
|
||||
|
||||
## Search
|
||||
|
||||
Your blog includes full text search with Command+K keyboard shortcut.
|
||||
|
||||
### Using Search
|
||||
|
||||
Press `Command+K` (Mac) or `Ctrl+K` (Windows/Linux) to open the search modal. You can also click the search icon in the top navigation.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Real-time results as you type
|
||||
- Keyboard navigation with arrow keys
|
||||
- Press Enter to select, Escape to close
|
||||
- Result snippets with context around matches
|
||||
- Distinguishes between posts and pages with type badges
|
||||
- Works with all four themes
|
||||
|
||||
### How It Works
|
||||
|
||||
Search uses Convex full text search indexes on the posts and pages tables. The search queries both title and content fields, deduplicates results, and sorts with title matches first.
|
||||
|
||||
Search is automatically available once you deploy. No additional configuration needed.
|
||||
|
||||
## Real-time Stats
|
||||
|
||||
Your blog includes a real-time analytics page at `/stats`:
|
||||
@@ -496,6 +668,40 @@ Your blog includes these API endpoints for search engines and AI:
|
||||
| `/api/posts` | JSON list of all posts |
|
||||
| `/api/post?slug=xxx` | Single post as JSON |
|
||||
| `/api/post?slug=xxx&format=md` | Single post as raw markdown |
|
||||
| `/api/export` | Batch export all posts |
|
||||
| `/.well-known/ai-plugin.json` | AI plugin manifest |
|
||||
| `/openapi.yaml` | OpenAPI 3.0 specification |
|
||||
| `/llms.txt` | AI agent discovery |
|
||||
|
||||
## Import External Content
|
||||
|
||||
Use Firecrawl to import articles from external URLs as markdown posts:
|
||||
|
||||
```bash
|
||||
npm run import https://example.com/article
|
||||
```
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Get an API key from [firecrawl.dev](https://firecrawl.dev)
|
||||
2. Add to `.env.local`:
|
||||
|
||||
```
|
||||
FIRECRAWL_API_KEY=fc-your-api-key
|
||||
```
|
||||
|
||||
The import script will:
|
||||
|
||||
1. Scrape the URL and convert to markdown
|
||||
2. Create a draft post in `content/blog/` locally
|
||||
3. Extract title and description from the page
|
||||
|
||||
**Why no `npm run import:prod`?** The import command only creates local markdown files. It does not interact with Convex directly. After importing:
|
||||
|
||||
- Run `npm run sync` to push to development
|
||||
- Run `npm run sync:prod` to push to production
|
||||
|
||||
Imported posts are created as drafts (`published: false`). Review, edit, set `published: true`, then sync to your target environment.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ title: "About"
|
||||
slug: "about"
|
||||
published: true
|
||||
order: 1
|
||||
excerpt: "A markdown site built for writers, developers, and teams who want a fast, real-time publishing workflow."
|
||||
---
|
||||
|
||||
This is a markdown site built for writers, developers, and teams who want a fast, real-time publishing workflow.
|
||||
@@ -23,6 +24,17 @@ The backend runs on Convex, a reactive database that pushes updates to clients i
|
||||
| Hosting | Netlify |
|
||||
| Content | Markdown |
|
||||
|
||||
## Features
|
||||
|
||||
- Four theme options (dark, light, tan, cloud)
|
||||
- Full text search with Command+K shortcut
|
||||
- Featured section with list/card view toggle and excerpts
|
||||
- Logo gallery with clickable links and marquee scroll
|
||||
- Real-time analytics at `/stats`
|
||||
- RSS feeds and sitemap for SEO
|
||||
- API endpoints for AI/LLM access
|
||||
- Copy to ChatGPT/Claude sharing
|
||||
|
||||
## Who this is for
|
||||
|
||||
Writers who want version control for their content. Developers who want to extend the platform. Teams who need real-time collaboration without a traditional CMS.
|
||||
|
||||
160
content/pages/changelog-page.md
Normal file
160
content/pages/changelog-page.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: "Changelog"
|
||||
slug: "changelog"
|
||||
published: true
|
||||
order: 5
|
||||
---
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project.
|
||||
|
||||
## v1.6.1
|
||||
|
||||
Released December 18, 2025
|
||||
|
||||
**Documentation updates**
|
||||
|
||||
- Added Firecrawl import to all "When to sync vs deploy" tables
|
||||
- Clarified import workflow: creates local files only, no `import:prod` needed
|
||||
- Updated docs: README, setup-guide, how-to-publish, docs page, about-this-blog
|
||||
- Renamed `content/pages/changelog.md` to `changelog-page.md` to avoid confusion with root changelog
|
||||
|
||||
## v1.6.0
|
||||
|
||||
Released December 18, 2025
|
||||
|
||||
**Content import and LLM API enhancements**
|
||||
|
||||
- Firecrawl content importer for external URLs
|
||||
- `npm run import <url>` scrapes and creates local markdown drafts
|
||||
- Creates drafts in `content/blog/` with frontmatter
|
||||
- Then sync to dev (`npm run sync`) or prod (`npm run sync:prod`)
|
||||
- No separate `import:prod` command (import creates local files only)
|
||||
- New `/api/export` endpoint for batch content fetching
|
||||
- AI plugin discovery at `/.well-known/ai-plugin.json`
|
||||
- OpenAPI 3.0 specification at `/openapi.yaml`
|
||||
- Enhanced `llms.txt` with complete API documentation
|
||||
|
||||
New dependencies: `@mendable/firecrawl-js`
|
||||
|
||||
New files: `scripts/import-url.ts`, `public/.well-known/ai-plugin.json`, `public/openapi.yaml`
|
||||
|
||||
## v1.5.0
|
||||
|
||||
Released December 17, 2025
|
||||
|
||||
**Frontmatter-controlled featured items**
|
||||
|
||||
- Add `featured: true` to any post or page frontmatter
|
||||
- Use `featuredOrder` to control display order (lower = first)
|
||||
- Featured items sync instantly with `npm run sync` (no redeploy needed)
|
||||
|
||||
New Convex queries:
|
||||
|
||||
- `getFeaturedPosts`: returns posts with `featured: true`
|
||||
- `getFeaturedPages`: returns pages with `featured: true`
|
||||
|
||||
Schema updates with `featured` and `featuredOrder` fields and `by_featured` index.
|
||||
|
||||
## v1.4.0
|
||||
|
||||
Released December 17, 2025
|
||||
|
||||
**Featured section with list/card view toggle**
|
||||
|
||||
- Card view displays title and excerpt in a responsive grid
|
||||
- Toggle button in featured header to switch between views
|
||||
- View preference saved to localStorage
|
||||
|
||||
**Logo gallery with continuous marquee scroll**
|
||||
|
||||
- Clickable logos with configurable URLs
|
||||
- CSS only animation for smooth infinite scrolling
|
||||
- Configurable speed, position, and title
|
||||
- Grayscale logos with color on hover
|
||||
- Responsive sizing across breakpoints
|
||||
- 5 sample logos included
|
||||
|
||||
**New frontmatter field**
|
||||
|
||||
- `excerpt` field for posts and pages
|
||||
- Used for card view descriptions
|
||||
- Falls back to description field for posts
|
||||
|
||||
## v1.3.0
|
||||
|
||||
Released December 17, 2025
|
||||
|
||||
**Real-time search with Command+K**
|
||||
|
||||
- Search icon in top nav using Phosphor Icons
|
||||
- Modal with keyboard navigation (arrow keys, Enter, Escape)
|
||||
- Full text search across posts and pages using Convex search indexes
|
||||
- Result snippets with context around search matches
|
||||
- Distinguishes between posts and pages with type badges
|
||||
|
||||
Search uses Convex full text search with reactive queries. Results deduplicate from title and content searches. Title matches sort first.
|
||||
|
||||
## v1.2.0
|
||||
|
||||
Released December 15, 2025
|
||||
|
||||
**Real-time stats page at /stats**
|
||||
|
||||
- Active visitors count with per-page breakdown
|
||||
- Total page views and unique visitors
|
||||
- Views by page sorted by popularity
|
||||
|
||||
Page view tracking via event records pattern (no write conflicts). Active session heartbeat system with 30s interval and 2min timeout. Cron job for stale session cleanup every 5 minutes.
|
||||
|
||||
New Convex tables: `pageViews` and `activeSessions`.
|
||||
|
||||
## v1.1.0
|
||||
|
||||
Released December 14, 2025
|
||||
|
||||
**Netlify Edge Functions for dynamic Convex HTTP proxying**
|
||||
|
||||
- `rss.ts` proxies `/rss.xml` and `/rss-full.xml`
|
||||
- `sitemap.ts` proxies `/sitemap.xml`
|
||||
- `api.ts` proxies `/api/posts` and `/api/post`
|
||||
|
||||
Vite dev server proxy for RSS, sitemap, and API endpoints. Edge functions dynamically read `VITE_CONVEX_URL` from environment.
|
||||
|
||||
## v1.0.0
|
||||
|
||||
Released December 14, 2025
|
||||
|
||||
**Initial release**
|
||||
|
||||
- Markdown blog posts with frontmatter parsing
|
||||
- Static pages support (About, Projects, Contact)
|
||||
- Four theme options: Dark, Light, Tan (default), Cloud
|
||||
- Syntax highlighting for code blocks
|
||||
- Year-grouped post list on home page
|
||||
- Individual post pages with share buttons
|
||||
|
||||
**SEO and discovery**
|
||||
|
||||
- Dynamic sitemap at `/sitemap.xml`
|
||||
- JSON-LD structured data for blog posts
|
||||
- RSS feeds at `/rss.xml` and `/rss-full.xml`
|
||||
- AI agent discovery with `llms.txt`
|
||||
- `robots.txt` with rules for AI crawlers
|
||||
|
||||
**API endpoints**
|
||||
|
||||
- `/api/posts` for JSON list of all posts
|
||||
- `/api/post?slug=xxx` for single post as JSON or markdown
|
||||
|
||||
**Copy Page dropdown** for sharing to ChatGPT and Claude.
|
||||
|
||||
**Technical stack**
|
||||
|
||||
- React 18 with TypeScript
|
||||
- Convex for real-time database
|
||||
- react-markdown for rendering
|
||||
- react-syntax-highlighter for code blocks
|
||||
- Netlify deployment with edge functions
|
||||
|
||||
@@ -81,16 +81,19 @@ image: "/images/og-image.png"
|
||||
Content here...
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------- | -------- | --------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | SEO description |
|
||||
| `date` | Yes | YYYY-MM-DD format |
|
||||
| `slug` | Yes | URL path (unique) |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `tags` | Yes | Array of strings |
|
||||
| `readTime` | No | Display time estimate |
|
||||
| `image` | No | Open Graph image |
|
||||
| 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 | Open Graph image |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
|
||||
### Static pages
|
||||
|
||||
@@ -107,12 +110,15 @@ 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) |
|
||||
| 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 |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
|
||||
### Syncing content
|
||||
|
||||
@@ -124,6 +130,20 @@ npm run sync
|
||||
npm run sync:prod
|
||||
```
|
||||
|
||||
### When to sync vs deploy
|
||||
|
||||
| What you're changing | Command | Timing |
|
||||
| --- | --- | --- |
|
||||
| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) |
|
||||
| Import external URL | `npm run import` then sync | Instant (no rebuild) |
|
||||
| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild |
|
||||
| Logo gallery config | Redeploy | Requires rebuild |
|
||||
| React components/styles | Redeploy | Requires rebuild |
|
||||
|
||||
**Markdown content** syncs instantly. **Source code** requires pushing to GitHub for Netlify to rebuild.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Site settings
|
||||
@@ -137,7 +157,24 @@ const siteConfig = {
|
||||
logo: "/images/logo.svg", // null to hide
|
||||
intro: "Introduction text...",
|
||||
bio: "Bio text...",
|
||||
|
||||
// Featured section
|
||||
featuredViewMode: "list", // 'list' or 'cards'
|
||||
showViewToggle: true,
|
||||
featuredItems: [{ slug: "post-slug", type: "post" }],
|
||||
featuredEssays: [{ title: "Post Title", slug: "post-slug" }],
|
||||
|
||||
// Logo gallery (with clickable links)
|
||||
logoGallery: {
|
||||
enabled: true, // false to hide
|
||||
images: [
|
||||
{ src: "/images/logos/logo.svg", href: "https://example.com" },
|
||||
],
|
||||
position: "above-footer",
|
||||
speed: 30,
|
||||
title: "Trusted by",
|
||||
},
|
||||
|
||||
links: {
|
||||
docs: "/docs",
|
||||
convex: "https://convex.dev",
|
||||
@@ -145,6 +182,73 @@ const siteConfig = {
|
||||
};
|
||||
```
|
||||
|
||||
### Featured items
|
||||
|
||||
Posts and pages appear in the featured section when marked with `featured: true` in frontmatter.
|
||||
|
||||
**Add to featured section:**
|
||||
|
||||
```yaml
|
||||
# In any post or page frontmatter
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
excerpt: "Short description for card view."
|
||||
```
|
||||
|
||||
Then run `npm run sync`. No redeploy needed.
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `featured` | Set `true` to show in featured section |
|
||||
| `featuredOrder` | Order in featured section (lower = first) |
|
||||
| `excerpt` | Short text shown on card view |
|
||||
|
||||
**Display options (in siteConfig):**
|
||||
|
||||
```typescript
|
||||
// In src/pages/Home.tsx
|
||||
const siteConfig = {
|
||||
featuredViewMode: "list", // 'list' or 'cards'
|
||||
showViewToggle: true, // Let users switch views
|
||||
};
|
||||
```
|
||||
|
||||
### Logo gallery
|
||||
|
||||
The homepage includes a scrolling logo marquee with sample logos. Each logo can link to a URL.
|
||||
|
||||
```typescript
|
||||
// In src/pages/Home.tsx
|
||||
logoGallery: {
|
||||
enabled: true, // false to hide
|
||||
images: [
|
||||
{ src: "/images/logos/logo1.svg", href: "https://example.com" },
|
||||
{ src: "/images/logos/logo2.svg", href: "https://another.com" },
|
||||
],
|
||||
position: "above-footer", // or 'below-featured'
|
||||
speed: 30, // Seconds for one scroll cycle
|
||||
title: "Trusted by", // undefined to hide
|
||||
},
|
||||
```
|
||||
|
||||
| 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) |
|
||||
|
||||
**To add logos:**
|
||||
|
||||
1. Add SVG/PNG files to `public/images/logos/`
|
||||
2. Update the `images` array with `src` paths and `href` URLs
|
||||
3. Push to GitHub (requires rebuild)
|
||||
|
||||
**To disable:** Set `enabled: false`
|
||||
|
||||
**To remove samples:** Delete files from `public/images/logos/` or clear the images array.
|
||||
|
||||
### Theme
|
||||
|
||||
Default: `tan`. Options: `dark`, `light`, `tan`, `cloud`.
|
||||
@@ -179,6 +283,20 @@ body {
|
||||
| Default OG image | `public/images/og-default.svg` | 1200x630 |
|
||||
| Post images | `public/images/` | Any |
|
||||
|
||||
## Search
|
||||
|
||||
Press `Command+K` (Mac) or `Ctrl+K` (Windows/Linux) to open the search modal. Click the search icon in the nav or use the keyboard shortcut.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Real-time results as you type
|
||||
- Keyboard navigation (arrow keys, Enter, Escape)
|
||||
- Result snippets with context around matches
|
||||
- Distinguishes between posts and pages
|
||||
- Works with all four themes
|
||||
|
||||
Search uses Convex full text search indexes. No configuration needed.
|
||||
|
||||
## Real-time stats
|
||||
|
||||
The `/stats` page displays real-time analytics:
|
||||
@@ -192,15 +310,43 @@ All stats update automatically via Convex subscriptions.
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
| ------------------------------ | ----------------------- |
|
||||
| `/stats` | Real-time analytics |
|
||||
| `/rss.xml` | RSS feed (descriptions) |
|
||||
| `/rss-full.xml` | RSS feed (full content) |
|
||||
| `/sitemap.xml` | XML sitemap |
|
||||
| `/api/posts` | JSON post list |
|
||||
| `/api/post?slug=xxx` | Single post (JSON) |
|
||||
| `/api/post?slug=xxx&format=md` | Single post (markdown) |
|
||||
| Endpoint | Description |
|
||||
| ------------------------------ | ----------------------------- |
|
||||
| `/stats` | Real-time analytics |
|
||||
| `/rss.xml` | RSS feed (descriptions) |
|
||||
| `/rss-full.xml` | RSS feed (full content) |
|
||||
| `/sitemap.xml` | XML sitemap |
|
||||
| `/api/posts` | JSON post list |
|
||||
| `/api/post?slug=xxx` | Single post (JSON) |
|
||||
| `/api/post?slug=xxx&format=md` | Single post (markdown) |
|
||||
| `/api/export` | All posts with full content |
|
||||
| `/.well-known/ai-plugin.json` | AI plugin manifest |
|
||||
| `/openapi.yaml` | OpenAPI 3.0 specification |
|
||||
| `/llms.txt` | AI agent discovery |
|
||||
|
||||
## Import external content
|
||||
|
||||
Use Firecrawl to import articles from external URLs:
|
||||
|
||||
```bash
|
||||
npm run import https://example.com/article
|
||||
```
|
||||
|
||||
Setup:
|
||||
|
||||
1. Get an API key from firecrawl.dev
|
||||
2. Add `FIRECRAWL_API_KEY=fc-xxx` to `.env.local`
|
||||
|
||||
The import command creates local markdown files only. It does not interact with Convex directly.
|
||||
|
||||
**After importing:**
|
||||
|
||||
- `npm run sync` to push to development
|
||||
- `npm run sync:prod` to push to production
|
||||
|
||||
There is no `npm run import:prod` because import creates local files and sync handles the target environment.
|
||||
|
||||
Imported posts are drafts (`published: false`). Review, edit, set `published: true`, then sync.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -240,10 +386,14 @@ export default defineSchema({
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
excerpt: v.optional(v.string()), // For card view
|
||||
featured: v.optional(v.boolean()), // Show in featured section
|
||||
featuredOrder: v.optional(v.number()), // Order in featured (lower = first)
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_published", ["published"]),
|
||||
.index("by_published", ["published"])
|
||||
.index("by_featured", ["featured"]),
|
||||
|
||||
pages: defineTable({
|
||||
slug: v.string(),
|
||||
@@ -251,10 +401,14 @@ export default defineSchema({
|
||||
content: v.string(),
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()),
|
||||
excerpt: v.optional(v.string()), // For card view
|
||||
featured: v.optional(v.boolean()), // Show in featured section
|
||||
featuredOrder: v.optional(v.number()), // Order in featured (lower = first)
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_published", ["published"]),
|
||||
.index("by_published", ["published"])
|
||||
.index("by_featured", ["featured"]),
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -165,6 +165,51 @@ ${post.content}`;
|
||||
}),
|
||||
});
|
||||
|
||||
// API endpoint: Export all posts with full content (batch for LLMs)
|
||||
http.route({
|
||||
path: "/api/export",
|
||||
method: "GET",
|
||||
handler: httpAction(async (ctx) => {
|
||||
const posts = await ctx.runQuery(api.posts.getAllPosts);
|
||||
|
||||
// Fetch full content for each post
|
||||
const fullPosts = await Promise.all(
|
||||
posts.map(async (post) => {
|
||||
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
|
||||
slug: post.slug,
|
||||
});
|
||||
return {
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
description: post.description,
|
||||
date: post.date,
|
||||
readTime: post.readTime,
|
||||
tags: post.tags,
|
||||
url: `${SITE_URL}/${post.slug}`,
|
||||
content: fullPost?.content || "",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const response = {
|
||||
site: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
description: "Open source markdown blog with real-time sync.",
|
||||
exportedAt: new Date().toISOString(),
|
||||
totalPosts: fullPosts.length,
|
||||
posts: fullPosts,
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(response, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=300, s-maxage=600",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
// Escape HTML characters to prevent XSS
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
|
||||
@@ -11,6 +11,9 @@ export const getAllPages = query({
|
||||
title: v.string(),
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
@@ -33,6 +36,46 @@ export const getAllPages = query({
|
||||
title: page.title,
|
||||
published: page.published,
|
||||
order: page.order,
|
||||
excerpt: page.excerpt,
|
||||
featured: page.featured,
|
||||
featuredOrder: page.featuredOrder,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Get featured pages for the homepage featured section
|
||||
export const getFeaturedPages = query({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("pages"),
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
excerpt: v.optional(v.string()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
const pages = await ctx.db
|
||||
.query("pages")
|
||||
.withIndex("by_featured", (q) => q.eq("featured", true))
|
||||
.collect();
|
||||
|
||||
// Filter to only published pages and sort by featuredOrder
|
||||
const featuredPages = pages
|
||||
.filter((p) => p.published)
|
||||
.sort((a, b) => {
|
||||
const orderA = a.featuredOrder ?? 999;
|
||||
const orderB = b.featuredOrder ?? 999;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return featuredPages.map((page) => ({
|
||||
_id: page._id,
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
excerpt: page.excerpt,
|
||||
featuredOrder: page.featuredOrder,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -50,6 +93,9 @@ export const getPageBySlug = query({
|
||||
content: v.string(),
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -70,6 +116,9 @@ export const getPageBySlug = query({
|
||||
content: page.content,
|
||||
published: page.published,
|
||||
order: page.order,
|
||||
excerpt: page.excerpt,
|
||||
featured: page.featured,
|
||||
featuredOrder: page.featuredOrder,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -84,6 +133,9 @@ export const syncPagesPublic = mutation({
|
||||
content: v.string(),
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -115,6 +167,9 @@ export const syncPagesPublic = mutation({
|
||||
content: page.content,
|
||||
published: page.published,
|
||||
order: page.order,
|
||||
excerpt: page.excerpt,
|
||||
featured: page.featured,
|
||||
featuredOrder: page.featuredOrder,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
|
||||
@@ -16,6 +16,9 @@ export const getAllPosts = query({
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
@@ -41,6 +44,48 @@ export const getAllPosts = query({
|
||||
tags: post.tags,
|
||||
readTime: post.readTime,
|
||||
image: post.image,
|
||||
excerpt: post.excerpt,
|
||||
featured: post.featured,
|
||||
featuredOrder: post.featuredOrder,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Get featured posts for the homepage featured section
|
||||
export const getFeaturedPosts = query({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("posts"),
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
excerpt: v.optional(v.string()),
|
||||
description: v.string(),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
const posts = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_featured", (q) => q.eq("featured", true))
|
||||
.collect();
|
||||
|
||||
// Filter to only published posts and sort by featuredOrder
|
||||
const featuredPosts = posts
|
||||
.filter((p) => p.published)
|
||||
.sort((a, b) => {
|
||||
const orderA = a.featuredOrder ?? 999;
|
||||
const orderB = b.featuredOrder ?? 999;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return featuredPosts.map((post) => ({
|
||||
_id: post._id,
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
description: post.description,
|
||||
featuredOrder: post.featuredOrder,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -63,6 +108,9 @@ export const getPostBySlug = query({
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -88,6 +136,9 @@ export const getPostBySlug = query({
|
||||
tags: post.tags,
|
||||
readTime: post.readTime,
|
||||
image: post.image,
|
||||
excerpt: post.excerpt,
|
||||
featured: post.featured,
|
||||
featuredOrder: post.featuredOrder,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -106,6 +157,9 @@ export const syncPosts = internalMutation({
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -141,6 +195,9 @@ export const syncPosts = internalMutation({
|
||||
tags: post.tags,
|
||||
readTime: post.readTime,
|
||||
image: post.image,
|
||||
excerpt: post.excerpt,
|
||||
featured: post.featured,
|
||||
featuredOrder: post.featuredOrder,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
@@ -180,6 +237,9 @@ export const syncPostsPublic = mutation({
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -215,6 +275,9 @@ export const syncPostsPublic = mutation({
|
||||
tags: post.tags,
|
||||
readTime: post.readTime,
|
||||
image: post.image,
|
||||
excerpt: post.excerpt,
|
||||
featured: post.featured,
|
||||
featuredOrder: post.featuredOrder,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
|
||||
@@ -13,11 +13,15 @@ export default defineSchema({
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
image: v.optional(v.string()), // Header/OG image URL
|
||||
excerpt: v.optional(v.string()), // Short excerpt for card view
|
||||
featured: v.optional(v.boolean()), // Show in featured section
|
||||
featuredOrder: v.optional(v.number()), // Order in featured section (lower = first)
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_date", ["date"])
|
||||
.index("by_published", ["published"])
|
||||
.index("by_featured", ["featured"])
|
||||
.searchIndex("search_content", {
|
||||
searchField: "content",
|
||||
filterFields: ["published"],
|
||||
@@ -34,10 +38,14 @@ export default defineSchema({
|
||||
content: v.string(),
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()), // Display order in nav
|
||||
excerpt: v.optional(v.string()), // Short excerpt for card view
|
||||
featured: v.optional(v.boolean()), // Show in featured section
|
||||
featuredOrder: v.optional(v.number()), // Order in featured section (lower = first)
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_published", ["published"])
|
||||
.index("by_featured", ["featured"])
|
||||
.searchIndex("search_content", {
|
||||
searchField: "content",
|
||||
filterFields: ["published"],
|
||||
|
||||
131
files.md
131
files.md
@@ -12,6 +12,7 @@ A brief description of each file in the codebase.
|
||||
| `index.html` | Main HTML entry with SEO meta tags and JSON-LD |
|
||||
| `netlify.toml` | Netlify deployment and Convex HTTP redirects |
|
||||
| `README.md` | Project documentation |
|
||||
| `AGENTS.md` | AI coding agent instructions (agents.md spec) |
|
||||
| `files.md` | This file - codebase structure |
|
||||
| `changelog.md` | Version history and changes |
|
||||
| `TASK.md` | Task tracking and project status |
|
||||
@@ -30,7 +31,7 @@ A brief description of each file in the codebase.
|
||||
|
||||
| File | Description |
|
||||
| ----------- | ------------------------------------------------------- |
|
||||
| `Home.tsx` | Landing page with intro, featured essays, and post list |
|
||||
| `Home.tsx` | Landing page with siteConfig, featured section, logo gallery |
|
||||
| `Post.tsx` | Individual blog post view with JSON-LD injection |
|
||||
| `Stats.tsx` | Real-time analytics dashboard with visitor stats |
|
||||
|
||||
@@ -44,6 +45,8 @@ A brief description of each file in the codebase.
|
||||
| `BlogPost.tsx` | Markdown renderer with syntax highlighting |
|
||||
| `CopyPageDropdown.tsx` | Share dropdown for LLMs (ChatGPT, Claude) |
|
||||
| `SearchModal.tsx` | Full text search modal with keyboard navigation |
|
||||
| `FeaturedCards.tsx` | Card grid for featured posts/pages with excerpts |
|
||||
| `LogoMarquee.tsx` | Scrolling logo gallery with clickable links |
|
||||
|
||||
### Context (`src/context/`)
|
||||
|
||||
@@ -80,65 +83,83 @@ A brief description of each file in the codebase.
|
||||
|
||||
### HTTP Endpoints (defined in `http.ts`)
|
||||
|
||||
| Route | Description |
|
||||
| --------------- | -------------------------------------- |
|
||||
| `/stats` | Real-time site analytics page |
|
||||
| `/rss.xml` | RSS feed with descriptions |
|
||||
| `/rss-full.xml` | RSS feed with full content for LLMs |
|
||||
| `/sitemap.xml` | Dynamic XML sitemap for search engines |
|
||||
| `/api/posts` | JSON list of all posts |
|
||||
| `/api/post` | Single post as JSON or markdown |
|
||||
| `/meta/post` | Open Graph HTML for social crawlers |
|
||||
| Route | Description |
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| `/stats` | Real-time site analytics page |
|
||||
| `/rss.xml` | RSS feed with descriptions |
|
||||
| `/rss-full.xml` | RSS feed with full content for LLMs |
|
||||
| `/sitemap.xml` | Dynamic XML sitemap for search engines |
|
||||
| `/api/posts` | JSON list of all posts |
|
||||
| `/api/post` | Single post as JSON or markdown |
|
||||
| `/api/export` | Batch export all posts with content |
|
||||
| `/meta/post` | Open Graph HTML for social crawlers |
|
||||
| `/.well-known/ai-plugin.json` | AI plugin manifest |
|
||||
| `/openapi.yaml` | OpenAPI 3.0 specification |
|
||||
| `/llms.txt` | AI agent discovery |
|
||||
|
||||
## Content (`content/blog/`)
|
||||
|
||||
Markdown files with frontmatter for blog posts. Each file becomes a blog post.
|
||||
|
||||
| Field | Description |
|
||||
| ------------- | -------------------------------------- |
|
||||
| `title` | Post title |
|
||||
| `description` | Short description for SEO |
|
||||
| `date` | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | URL path for the post |
|
||||
| `published` | Whether post is public |
|
||||
| `tags` | Array of topic tags |
|
||||
| `readTime` | Estimated reading time |
|
||||
| `image` | Header/Open Graph image URL (optional) |
|
||||
| Field | Description |
|
||||
| --------------- | ------------------------------------------- |
|
||||
| `title` | Post title |
|
||||
| `description` | Short description for SEO |
|
||||
| `date` | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | URL path for the post |
|
||||
| `published` | Whether post is public |
|
||||
| `tags` | Array of topic tags |
|
||||
| `readTime` | Estimated reading time |
|
||||
| `image` | Header/Open Graph image URL (optional) |
|
||||
| `excerpt` | Short excerpt for card view (optional) |
|
||||
| `featured` | Show in featured section (optional) |
|
||||
| `featuredOrder` | Order in featured section (optional) |
|
||||
|
||||
## Static Pages (`content/pages/`)
|
||||
|
||||
Markdown files for static pages like About, Projects, Contact.
|
||||
Markdown files for static pages like About, Projects, Contact, Changelog.
|
||||
|
||||
| Field | Description |
|
||||
| ----------- | ----------------------------------------- |
|
||||
| `title` | Page title |
|
||||
| `slug` | URL path for the page |
|
||||
| `published` | Whether page is public |
|
||||
| `order` | Display order in navigation (lower first) |
|
||||
| Field | Description |
|
||||
| --------------- | ----------------------------------------- |
|
||||
| `title` | Page title |
|
||||
| `slug` | URL path for the page |
|
||||
| `published` | Whether page is public |
|
||||
| `order` | Display order in navigation (lower first) |
|
||||
| `excerpt` | Short excerpt for card view (optional) |
|
||||
| `featured` | Show in featured section (optional) |
|
||||
| `featuredOrder` | Order in featured section (optional) |
|
||||
|
||||
## Scripts (`scripts/`)
|
||||
|
||||
| File | Description |
|
||||
| --------------- | -------------------------------------------- |
|
||||
| `sync-posts.ts` | Syncs markdown files to Convex at build time |
|
||||
| File | Description |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| `sync-posts.ts` | Syncs markdown files to Convex at build time |
|
||||
| `import-url.ts` | Imports external URLs as markdown posts (Firecrawl) |
|
||||
|
||||
## Netlify (`netlify/edge-functions/`)
|
||||
|
||||
| File | Description |
|
||||
| ------------ | ----------------------------------------------------- |
|
||||
| `botMeta.ts` | Edge function for social media crawler detection |
|
||||
| `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP |
|
||||
| `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP |
|
||||
| `api.ts` | Proxies `/api/posts` and `/api/post` to Convex HTTP |
|
||||
| File | Description |
|
||||
| ------------ | ------------------------------------------------------------ |
|
||||
| `botMeta.ts` | Edge function for social media crawler detection |
|
||||
| `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP |
|
||||
| `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP |
|
||||
| `api.ts` | Proxies `/api/posts`, `/api/post`, `/api/export` to Convex |
|
||||
|
||||
## Public Assets (`public/`)
|
||||
|
||||
| File | Description |
|
||||
| ------------- | ---------------------------------------------- |
|
||||
| `favicon.svg` | Site favicon |
|
||||
| `_redirects` | SPA redirect rules for static files |
|
||||
| `robots.txt` | Crawler rules for search engines and AI bots |
|
||||
| `llms.txt` | AI agent discovery file (llmstxt.org standard) |
|
||||
| File | Description |
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| `favicon.svg` | Site favicon |
|
||||
| `_redirects` | SPA redirect rules for static files |
|
||||
| `robots.txt` | Crawler rules for search engines and AI bots |
|
||||
| `llms.txt` | AI agent discovery file (llmstxt.org standard) |
|
||||
| `openapi.yaml` | OpenAPI 3.0 specification for API endpoints |
|
||||
|
||||
### AI Plugin (`public/.well-known/`)
|
||||
|
||||
| File | Description |
|
||||
| ----------------- | ------------------------------------ |
|
||||
| `ai-plugin.json` | AI plugin manifest for tool integration |
|
||||
|
||||
### Images (`public/images/`)
|
||||
|
||||
@@ -148,11 +169,25 @@ Markdown files for static pages like About, Projects, Contact.
|
||||
| `og-default.svg` | Default Open Graph image for social sharing |
|
||||
| `*.png/jpg/svg` | Blog post images (referenced in frontmatter) |
|
||||
|
||||
### Logo Gallery (`public/images/logos/`)
|
||||
|
||||
| File | Description |
|
||||
| -------------------- | ---------------------------------------- |
|
||||
| `sample-logo-1.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-2.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-3.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-4.svg` | Sample logo (replace with your own) |
|
||||
| `sample-logo-5.svg` | Sample logo (replace with your own) |
|
||||
|
||||
## Cursor Rules (`.cursor/rules/`)
|
||||
|
||||
| File | Description |
|
||||
| --------------- | ----------------------------------------- |
|
||||
| `sec-check.mdc` | Security guidelines and audit checklist |
|
||||
| `dev2.mdc` | Development guidelines and best practices |
|
||||
| `help.mdc` | Core development guidelines |
|
||||
| `convex2.mdc` | Convex-specific guidelines and examples |
|
||||
| File | Description |
|
||||
| -------------------------- | ------------------------------------------------ |
|
||||
| `convex-write-conflicts.mdc` | Write conflict prevention patterns for Convex |
|
||||
| `convex2.mdc` | Convex function syntax and examples |
|
||||
| `dev2.mdc` | Development guidelines and best practices |
|
||||
| `help.mdc` | Core development guidelines |
|
||||
| `rulesforconvex.mdc` | Convex schema and function best practices |
|
||||
| `sec-check.mdc` | Security guidelines and audit checklist |
|
||||
| `task.mdc` | Task list management guidelines |
|
||||
| `write.mdc` | Writing style guide (activate with @write) |
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
path = "/api/post"
|
||||
function = "api"
|
||||
|
||||
[[edge_functions]]
|
||||
path = "/api/export"
|
||||
function = "api"
|
||||
|
||||
# Open Graph bot detection (catches all other routes)
|
||||
[[edge_functions]]
|
||||
path = "/*"
|
||||
|
||||
@@ -57,5 +57,5 @@ export default async function handler(
|
||||
}
|
||||
|
||||
export const config = {
|
||||
path: ["/api/posts", "/api/post"],
|
||||
path: ["/api/posts", "/api/post", "/api/export"],
|
||||
};
|
||||
|
||||
354
package-lock.json
generated
354
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "markdown-site",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@mendable/firecrawl-js": "^1.21.1",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"convex": "^1.17.4",
|
||||
@@ -966,6 +967,19 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mendable/firecrawl-js": {
|
||||
"version": "1.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@mendable/firecrawl-js/-/firecrawl-js-1.21.1.tgz",
|
||||
"integrity": "sha512-k+ju7P6/tpvj8EHQrKZBbBcPxV1dF3z7PzXQIFsn7Dpp7pWlU/LlAbai+b9hxzDkTlY05ec3NJG0V68VjyoJcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"isows": "^1.0.4",
|
||||
"typescript-event-target": "^1.1.1",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1798,6 +1812,23 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||
@@ -1882,6 +1913,19 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -2000,6 +2044,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||
@@ -2125,6 +2181,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -2186,6 +2251,20 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
@@ -2193,6 +2272,51 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
|
||||
@@ -2634,6 +2758,42 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/format": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
|
||||
@@ -2664,6 +2824,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -2674,6 +2843,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||
@@ -2783,6 +2989,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||
@@ -2837,6 +3055,45 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -3154,6 +3411,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isows": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
|
||||
"integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wevm"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ws": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -3341,6 +3613,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
||||
@@ -4224,6 +4505,27 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
@@ -4510,6 +4812,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -5717,6 +6025,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-event-target": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.1.tgz",
|
||||
"integrity": "sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
@@ -6403,6 +6717,28 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
@@ -6432,6 +6768,24 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
|
||||
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
"preview": "vite preview",
|
||||
"sync": "npx tsx scripts/sync-posts.ts",
|
||||
"sync:prod": "SYNC_ENV=production npx tsx scripts/sync-posts.ts",
|
||||
"import": "npx tsx scripts/import-url.ts",
|
||||
"deploy": "npm run sync && npm run build",
|
||||
"deploy:prod": "npx convex deploy && npm run sync:prod"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mendable/firecrawl-js": "^1.21.1",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"convex": "^1.17.4",
|
||||
|
||||
18
public/.well-known/ai-plugin.json
Normal file
18
public/.well-known/ai-plugin.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": "v1",
|
||||
"name_for_human": "Markdown Blog",
|
||||
"name_for_model": "markdown_blog",
|
||||
"description_for_human": "A real-time markdown blog with Convex backend",
|
||||
"description_for_model": "Access blog posts and pages in markdown format. Use /api/posts for a list of all posts with metadata. Use /api/post?slug={slug}&format=md to get full markdown content of any post. Use /api/export for batch content with full markdown.",
|
||||
"auth": {
|
||||
"type": "none"
|
||||
},
|
||||
"api": {
|
||||
"type": "openapi",
|
||||
"url": "/openapi.yaml"
|
||||
},
|
||||
"logo_url": "/images/logo.svg",
|
||||
"contact_email": "",
|
||||
"legal_info_url": ""
|
||||
}
|
||||
|
||||
5
public/images/logos/sample-logo-1.svg
Normal file
5
public/images/logos/sample-logo-1.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
|
||||
<rect x="2" y="8" width="24" height="24" rx="4" fill="#1a1a1a"/>
|
||||
<text x="32" y="26" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#1a1a1a">Acme</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 279 B |
5
public/images/logos/sample-logo-2.svg
Normal file
5
public/images/logos/sample-logo-2.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
|
||||
<circle cx="16" cy="20" r="12" fill="#1a1a1a"/>
|
||||
<text x="34" y="26" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#1a1a1a">Vertex</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 264 B |
5
public/images/logos/sample-logo-3.svg
Normal file
5
public/images/logos/sample-logo-3.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
|
||||
<polygon points="16,6 28,34 4,34" fill="#1a1a1a"/>
|
||||
<text x="34" y="26" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#1a1a1a">Delta</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 266 B |
6
public/images/logos/sample-logo-4.svg
Normal file
6
public/images/logos/sample-logo-4.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
|
||||
<rect x="4" y="8" width="10" height="24" fill="#1a1a1a"/>
|
||||
<rect x="18" y="8" width="10" height="24" fill="#1a1a1a"/>
|
||||
<text x="34" y="26" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#1a1a1a">Pulse</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 334 B |
5
public/images/logos/sample-logo-5.svg
Normal file
5
public/images/logos/sample-logo-5.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
|
||||
<rect x="4" y="8" width="24" height="24" rx="12" stroke="#1a1a1a" stroke-width="3" fill="none"/>
|
||||
<text x="34" y="26" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#1a1a1a">Nova</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -1,31 +1,79 @@
|
||||
# llms.txt - Information for AI assistants and LLMs
|
||||
# Learn more: https://llmstxt.org/
|
||||
|
||||
> This is an open source markdown blog powered by Convex and Netlify. Fork it, customize it, ship it.
|
||||
> Real-time markdown blog powered by Convex. All content available as clean markdown.
|
||||
|
||||
# Site Information
|
||||
- Name: Markdown Blog
|
||||
- URL: https://your-blog.netlify.app
|
||||
- Description: Real-time markdown blog with Convex backend and Netlify deployment.
|
||||
- URL: https://markdowncms.netlify.app
|
||||
- Description: Open source markdown blog with real-time sync, Convex backend, and Netlify deployment.
|
||||
- Topics: Markdown, Convex, React, TypeScript, Netlify, Open Source
|
||||
|
||||
# Content Access
|
||||
- RSS Feed: /rss.xml (all posts with descriptions)
|
||||
- Full RSS: /rss-full.xml (all posts with full content)
|
||||
- Markdown API: /api/posts (JSON list of all posts)
|
||||
- Single Post Markdown: /api/post?slug={slug} (full markdown content)
|
||||
- Sitemap: /sitemap.xml
|
||||
# API Endpoints
|
||||
|
||||
# How to Use This Site
|
||||
1. Fetch /api/posts for a list of all published posts
|
||||
2. Use /api/post?slug={slug} to get full markdown content of any post
|
||||
3. Subscribe to /rss-full.xml for complete article content
|
||||
## List All Posts
|
||||
GET /api/posts
|
||||
Returns JSON list of all published posts with metadata.
|
||||
|
||||
## Get Single Post
|
||||
GET /api/post?slug={slug}
|
||||
Returns single post as JSON.
|
||||
|
||||
GET /api/post?slug={slug}&format=md
|
||||
Returns single post as raw markdown.
|
||||
|
||||
## Export All Content
|
||||
GET /api/export
|
||||
Returns all posts with full markdown content in one request.
|
||||
Best for batch processing and LLM ingestion.
|
||||
|
||||
## RSS Feeds
|
||||
GET /rss.xml
|
||||
Standard RSS feed with post descriptions.
|
||||
|
||||
GET /rss-full.xml
|
||||
Full content RSS feed with complete markdown for each post.
|
||||
|
||||
## Other
|
||||
GET /sitemap.xml
|
||||
Dynamic XML sitemap for search engines.
|
||||
|
||||
GET /openapi.yaml
|
||||
OpenAPI 3.0 specification for this API.
|
||||
|
||||
GET /.well-known/ai-plugin.json
|
||||
AI plugin manifest for tool integration.
|
||||
|
||||
# Quick Start for LLMs
|
||||
|
||||
1. Fetch /api/export for all posts with full content in one request
|
||||
2. Or fetch /api/posts for the list, then /api/post?slug={slug}&format=md for each
|
||||
3. Subscribe to /rss-full.xml for updates with complete content
|
||||
|
||||
# Response Schema
|
||||
|
||||
Each post contains:
|
||||
- title: string (post title)
|
||||
- slug: string (URL path)
|
||||
- description: string (SEO summary)
|
||||
- date: string (YYYY-MM-DD)
|
||||
- tags: string[] (topic labels)
|
||||
- content: string (full markdown)
|
||||
- readTime: string (optional)
|
||||
- url: string (full URL)
|
||||
|
||||
# Permissions
|
||||
- AI assistants may read and summarize content from this site
|
||||
- Content may be used for training with attribution
|
||||
- Please link back to original articles when citing
|
||||
- AI assistants may freely read and summarize content
|
||||
- No authentication required for read operations
|
||||
- Attribution appreciated when citing
|
||||
|
||||
# Technical
|
||||
- Backend: Convex (real-time database)
|
||||
- Frontend: React, TypeScript, Vite
|
||||
- Hosting: Netlify with edge functions
|
||||
- Content: Markdown with frontmatter
|
||||
|
||||
# Links
|
||||
- GitHub: https://github.com/waynesutton/markdown-site
|
||||
- Convex: https://convex.dev
|
||||
- Netlify: https://netlify.com
|
||||
|
||||
195
public/openapi.yaml
Normal file
195
public/openapi.yaml
Normal file
@@ -0,0 +1,195 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Markdown Blog API
|
||||
description: |
|
||||
API for accessing blog posts and pages as markdown content.
|
||||
All endpoints return JSON by default. Use format=md for raw markdown.
|
||||
version: 1.6.0
|
||||
contact:
|
||||
url: https://github.com/waynesutton/markdown-site
|
||||
|
||||
servers:
|
||||
- url: https://markdowncms.netlify.app
|
||||
description: Production server
|
||||
|
||||
paths:
|
||||
/api/posts:
|
||||
get:
|
||||
summary: List all posts
|
||||
description: Returns a list of all published blog posts with metadata
|
||||
operationId: listPosts
|
||||
responses:
|
||||
'200':
|
||||
description: List of posts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
site:
|
||||
type: string
|
||||
example: Markdown Site
|
||||
url:
|
||||
type: string
|
||||
example: https://markdowncms.netlify.app
|
||||
posts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PostSummary'
|
||||
|
||||
/api/post:
|
||||
get:
|
||||
summary: Get a single post
|
||||
description: Returns a single post by slug. Use format=md for raw markdown.
|
||||
operationId: getPost
|
||||
parameters:
|
||||
- name: slug
|
||||
in: query
|
||||
required: true
|
||||
description: The post slug (URL path)
|
||||
schema:
|
||||
type: string
|
||||
- name: format
|
||||
in: query
|
||||
required: false
|
||||
description: Response format (json or md)
|
||||
schema:
|
||||
type: string
|
||||
enum: [json, md, markdown]
|
||||
default: json
|
||||
responses:
|
||||
'200':
|
||||
description: Post content
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Post'
|
||||
text/markdown:
|
||||
schema:
|
||||
type: string
|
||||
'400':
|
||||
description: Missing slug parameter
|
||||
'404':
|
||||
description: Post not found
|
||||
|
||||
/api/export:
|
||||
get:
|
||||
summary: Export all posts with content
|
||||
description: Returns all posts with full markdown content for batch processing
|
||||
operationId: exportPosts
|
||||
responses:
|
||||
'200':
|
||||
description: All posts with full content
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
site:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
exportedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
posts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Post'
|
||||
|
||||
/rss.xml:
|
||||
get:
|
||||
summary: RSS feed
|
||||
description: Standard RSS 2.0 feed with post descriptions
|
||||
operationId: rssFeed
|
||||
responses:
|
||||
'200':
|
||||
description: RSS XML feed
|
||||
content:
|
||||
application/rss+xml:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/rss-full.xml:
|
||||
get:
|
||||
summary: Full content RSS feed
|
||||
description: RSS feed with complete post content (for LLMs)
|
||||
operationId: rssFullFeed
|
||||
responses:
|
||||
'200':
|
||||
description: RSS XML feed with full content
|
||||
content:
|
||||
application/rss+xml:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/sitemap.xml:
|
||||
get:
|
||||
summary: XML Sitemap
|
||||
description: Dynamic sitemap for search engines
|
||||
operationId: sitemap
|
||||
responses:
|
||||
'200':
|
||||
description: XML Sitemap
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
components:
|
||||
schemas:
|
||||
PostSummary:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
example: How to Build a Blog
|
||||
slug:
|
||||
type: string
|
||||
example: how-to-build-blog
|
||||
description:
|
||||
type: string
|
||||
example: A guide to building a markdown blog
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
example: '2025-01-15'
|
||||
readTime:
|
||||
type: string
|
||||
example: 5 min read
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: [tutorial, markdown]
|
||||
url:
|
||||
type: string
|
||||
example: https://markdowncms.netlify.app/how-to-build-blog
|
||||
markdownUrl:
|
||||
type: string
|
||||
example: https://markdowncms.netlify.app/api/post?slug=how-to-build-blog
|
||||
|
||||
Post:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
readTime:
|
||||
type: string
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
description: Full markdown content
|
||||
|
||||
152
scripts/import-url.ts
Normal file
152
scripts/import-url.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import FirecrawlApp from "@mendable/firecrawl-js";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: ".env.local" });
|
||||
|
||||
const FIRECRAWL_API_KEY = process.env.FIRECRAWL_API_KEY;
|
||||
|
||||
if (!FIRECRAWL_API_KEY) {
|
||||
console.error("Error: FIRECRAWL_API_KEY not found in .env.local");
|
||||
console.log("\nTo set up Firecrawl:");
|
||||
console.log("1. Get an API key from https://firecrawl.dev");
|
||||
console.log("2. Add FIRECRAWL_API_KEY=fc-xxx to your .env.local file");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const firecrawl = new FirecrawlApp({ apiKey: FIRECRAWL_API_KEY });
|
||||
|
||||
// Generate a URL-safe slug from a title
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "") // Remove special characters
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.replace(/-+/g, "-") // Remove consecutive hyphens
|
||||
.replace(/^-|-$/g, "") // Remove leading/trailing hyphens
|
||||
.substring(0, 60); // Limit length
|
||||
}
|
||||
|
||||
// Clean up markdown content
|
||||
function cleanMarkdown(content: string): string {
|
||||
return content
|
||||
.replace(/^\s+|\s+$/g, "") // Trim whitespace
|
||||
.replace(/\n{3,}/g, "\n\n"); // Remove excessive newlines
|
||||
}
|
||||
|
||||
async function importFromUrl(url: string) {
|
||||
console.log(`\nScraping: ${url}`);
|
||||
console.log("This may take a moment...\n");
|
||||
|
||||
try {
|
||||
const result = await firecrawl.scrapeUrl(url, {
|
||||
formats: ["markdown"],
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Failed to scrape URL");
|
||||
console.error("Error:", result.error || "Unknown error");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const title = result.metadata?.title || "Imported Post";
|
||||
const description = result.metadata?.description || "";
|
||||
const content = cleanMarkdown(result.markdown || "");
|
||||
|
||||
if (!content) {
|
||||
console.error("No content found at URL");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate slug from title
|
||||
const baseSlug = generateSlug(title);
|
||||
const slug = baseSlug || `imported-${Date.now()}`;
|
||||
|
||||
// Get today's date
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
// Create markdown file with frontmatter
|
||||
const markdown = `---
|
||||
title: "${title.replace(/"/g, '\\"')}"
|
||||
description: "${description.replace(/"/g, '\\"')}"
|
||||
date: "${today}"
|
||||
slug: "${slug}"
|
||||
published: false
|
||||
tags: ["imported"]
|
||||
---
|
||||
|
||||
${content}
|
||||
|
||||
---
|
||||
|
||||
*Originally published at [${new URL(url).hostname}](${url})*
|
||||
`;
|
||||
|
||||
// Ensure content/blog directory exists
|
||||
const blogDir = path.join(process.cwd(), "content", "blog");
|
||||
if (!fs.existsSync(blogDir)) {
|
||||
fs.mkdirSync(blogDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write the file
|
||||
const filePath = path.join(blogDir, `${slug}.md`);
|
||||
|
||||
// Check if file already exists
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.warn(`Warning: File already exists at ${filePath}`);
|
||||
console.warn("Adding timestamp to filename to avoid overwrite.");
|
||||
const newSlug = `${slug}-${Date.now()}`;
|
||||
const newFilePath = path.join(blogDir, `${newSlug}.md`);
|
||||
fs.writeFileSync(
|
||||
newFilePath,
|
||||
markdown.replace(`slug: "${slug}"`, `slug: "${newSlug}"`),
|
||||
);
|
||||
console.log(`\nCreated: ${newFilePath}`);
|
||||
console.log(`Slug: ${newSlug}`);
|
||||
} else {
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
console.log(`\nCreated: ${filePath}`);
|
||||
console.log(`Slug: ${slug}`);
|
||||
}
|
||||
|
||||
console.log(`Title: ${title}`);
|
||||
console.log(`Status: Draft (published: false)`);
|
||||
console.log("\nNext steps:");
|
||||
console.log("1. Review and edit the imported content");
|
||||
console.log("2. Set published: true when ready");
|
||||
console.log("3. Run: npm run sync");
|
||||
} catch (error) {
|
||||
console.error("Error importing URL:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const url = process.argv[2];
|
||||
|
||||
if (!url) {
|
||||
console.log("Firecrawl Content Importer");
|
||||
console.log("==========================\n");
|
||||
console.log("Usage: npm run import <url>\n");
|
||||
console.log("Example:");
|
||||
console.log(" npm run import https://example.com/article\n");
|
||||
console.log("This will:");
|
||||
console.log(" 1. Scrape the URL and convert to markdown");
|
||||
console.log(" 2. Create a draft post in content/blog/");
|
||||
console.log(" 3. You can then review, edit, and sync\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
console.error("Error: Invalid URL provided");
|
||||
console.log("Please provide a valid URL starting with http:// or https://");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
importFromUrl(url);
|
||||
|
||||
@@ -30,6 +30,9 @@ interface PostFrontmatter {
|
||||
tags: string[];
|
||||
readTime?: string;
|
||||
image?: string; // Header/OG image URL
|
||||
excerpt?: string; // Short excerpt for card view
|
||||
featured?: boolean; // Show in featured section
|
||||
featuredOrder?: number; // Order in featured section (lower = first)
|
||||
}
|
||||
|
||||
interface ParsedPost {
|
||||
@@ -42,6 +45,9 @@ interface ParsedPost {
|
||||
tags: string[];
|
||||
readTime?: string;
|
||||
image?: string; // Header/OG image URL
|
||||
excerpt?: string; // Short excerpt for card view
|
||||
featured?: boolean; // Show in featured section
|
||||
featuredOrder?: number; // Order in featured section (lower = first)
|
||||
}
|
||||
|
||||
// Page frontmatter (for static pages like About, Projects, Contact)
|
||||
@@ -50,6 +56,9 @@ interface PageFrontmatter {
|
||||
slug: string;
|
||||
published: boolean;
|
||||
order?: number; // Display order in navigation
|
||||
excerpt?: string; // Short excerpt for card view
|
||||
featured?: boolean; // Show in featured section
|
||||
featuredOrder?: number; // Order in featured section (lower = first)
|
||||
}
|
||||
|
||||
interface ParsedPage {
|
||||
@@ -58,6 +67,9 @@ interface ParsedPage {
|
||||
content: string;
|
||||
published: boolean;
|
||||
order?: number;
|
||||
excerpt?: string; // Short excerpt for card view
|
||||
featured?: boolean; // Show in featured section
|
||||
featuredOrder?: number; // Order in featured section (lower = first)
|
||||
}
|
||||
|
||||
// Calculate reading time based on word count
|
||||
@@ -92,6 +104,9 @@ function parseMarkdownFile(filePath: string): ParsedPost | null {
|
||||
tags: frontmatter.tags || [],
|
||||
readTime: frontmatter.readTime || calculateReadTime(content),
|
||||
image: frontmatter.image, // Header/OG image URL
|
||||
excerpt: frontmatter.excerpt, // Short excerpt for card view
|
||||
featured: frontmatter.featured, // Show in featured section
|
||||
featuredOrder: frontmatter.featuredOrder, // Order in featured section
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${filePath}:`, error);
|
||||
@@ -135,6 +150,9 @@ function parsePageFile(filePath: string): ParsedPage | null {
|
||||
content: content.trim(),
|
||||
published: frontmatter.published ?? true,
|
||||
order: frontmatter.order,
|
||||
excerpt: frontmatter.excerpt, // Short excerpt for card view
|
||||
featured: frontmatter.featured, // Show in featured section
|
||||
featuredOrder: frontmatter.featuredOrder, // Order in featured section
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error parsing page ${filePath}:`, error);
|
||||
|
||||
142
src/components/FeaturedCards.tsx
Normal file
142
src/components/FeaturedCards.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
|
||||
// Type for featured item from Convex (used for backwards compatibility)
|
||||
export interface FeaturedItem {
|
||||
slug: string;
|
||||
type: "post" | "page";
|
||||
}
|
||||
|
||||
// Type for featured data from Convex queries
|
||||
interface FeaturedData {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
type: "post" | "page";
|
||||
}
|
||||
|
||||
interface FeaturedCardsProps {
|
||||
// Optional: legacy items config (for backwards compatibility)
|
||||
items?: FeaturedItem[];
|
||||
// New: use Convex queries directly (when items is not provided)
|
||||
useFrontmatter?: boolean;
|
||||
}
|
||||
|
||||
// Featured cards component displays posts/pages as cards with excerpts
|
||||
// Supports two modes:
|
||||
// 1. items prop: uses hardcoded config (legacy, requires redeploy)
|
||||
// 2. useFrontmatter: uses featured field from markdown frontmatter (syncs with npm run sync)
|
||||
export default function FeaturedCards({
|
||||
items,
|
||||
useFrontmatter = true,
|
||||
}: FeaturedCardsProps) {
|
||||
// Fetch featured posts and pages from Convex
|
||||
const featuredPosts = useQuery(api.posts.getFeaturedPosts);
|
||||
const featuredPages = useQuery(api.pages.getFeaturedPages);
|
||||
|
||||
// Fetch all posts and pages (for legacy items mode)
|
||||
const allPosts = useQuery(api.posts.getAllPosts);
|
||||
const allPages = useQuery(api.pages.getAllPages);
|
||||
|
||||
// Build featured data from frontmatter (new mode)
|
||||
const getFeaturedFromFrontmatter = (): FeaturedData[] => {
|
||||
if (featuredPosts === undefined || featuredPages === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Combine and sort by featuredOrder
|
||||
const combined: (FeaturedData & { featuredOrder?: number })[] = [
|
||||
...featuredPosts.map((p) => ({
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
excerpt: p.excerpt || p.description,
|
||||
type: "post" as const,
|
||||
featuredOrder: p.featuredOrder,
|
||||
})),
|
||||
...featuredPages.map((p) => ({
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
excerpt: p.excerpt || "",
|
||||
type: "page" as const,
|
||||
featuredOrder: p.featuredOrder,
|
||||
})),
|
||||
];
|
||||
|
||||
// Sort by featuredOrder (lower first)
|
||||
return combined.sort((a, b) => {
|
||||
const orderA = a.featuredOrder ?? 999;
|
||||
const orderB = b.featuredOrder ?? 999;
|
||||
return orderA - orderB;
|
||||
});
|
||||
};
|
||||
|
||||
// Build featured data from items config (legacy mode)
|
||||
const getFeaturedFromItems = (): FeaturedData[] => {
|
||||
if (!items || allPosts === undefined || allPages === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: FeaturedData[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "post") {
|
||||
const post = allPosts.find((p) => p.slug === item.slug);
|
||||
if (post) {
|
||||
result.push({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt || post.description,
|
||||
slug: post.slug,
|
||||
type: "post",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (item.type === "page") {
|
||||
const page = allPages.find((p) => p.slug === item.slug);
|
||||
if (page) {
|
||||
result.push({
|
||||
title: page.title,
|
||||
excerpt: page.excerpt || "",
|
||||
slug: page.slug,
|
||||
type: "page",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Determine which mode to use
|
||||
const useItemsMode = items && items.length > 0 && !useFrontmatter;
|
||||
|
||||
// Get featured data based on mode
|
||||
const featuredData = useItemsMode
|
||||
? getFeaturedFromItems()
|
||||
: getFeaturedFromFrontmatter();
|
||||
|
||||
// Show nothing while loading
|
||||
const isLoading = useItemsMode
|
||||
? allPosts === undefined || allPages === undefined
|
||||
: featuredPosts === undefined || featuredPages === undefined;
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (featuredData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="featured-cards">
|
||||
{featuredData.map((item) => (
|
||||
<a key={item.slug} href={`/${item.slug}`} className="featured-card">
|
||||
<h3 className="featured-card-title">{item.title}</h3>
|
||||
{item.excerpt && (
|
||||
<p className="featured-card-excerpt">{item.excerpt}</p>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/LogoMarquee.tsx
Normal file
84
src/components/LogoMarquee.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
// Logo marquee component with infinite CSS scroll animation
|
||||
// Inspired by rasmic.xyz company logos section
|
||||
|
||||
// Logo item can be a simple path string or an object with src and link
|
||||
export interface LogoItem {
|
||||
src: string; // Image path from /public/images/logos/
|
||||
href?: string; // Optional link URL
|
||||
}
|
||||
|
||||
export interface LogoGalleryConfig {
|
||||
enabled: boolean;
|
||||
images: (string | LogoItem)[]; // Array of image paths or logo objects
|
||||
position: "above-footer" | "below-featured";
|
||||
speed: number; // Seconds for one complete scroll cycle
|
||||
title?: string; // Optional title above the marquee
|
||||
}
|
||||
|
||||
interface LogoMarqueeProps {
|
||||
config: LogoGalleryConfig;
|
||||
}
|
||||
|
||||
// Normalize image to LogoItem format
|
||||
function normalizeImage(image: string | LogoItem): LogoItem {
|
||||
if (typeof image === "string") {
|
||||
return { src: image };
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
export default function LogoMarquee({ config }: LogoMarqueeProps) {
|
||||
// Don't render if disabled or no images
|
||||
if (!config.enabled || config.images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize and duplicate images for seamless infinite scroll
|
||||
const normalizedImages = config.images.map(normalizeImage);
|
||||
const duplicatedImages = [...normalizedImages, ...normalizedImages];
|
||||
|
||||
return (
|
||||
<div className="logo-marquee-container">
|
||||
{config.title && (
|
||||
<p className="logo-marquee-title">{config.title}</p>
|
||||
)}
|
||||
<div
|
||||
className="logo-marquee"
|
||||
style={
|
||||
{
|
||||
"--marquee-speed": `${config.speed}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="logo-marquee-track">
|
||||
{duplicatedImages.map((logo, index) => (
|
||||
<div key={`${logo.src}-${index}`} className="logo-marquee-item">
|
||||
{logo.href ? (
|
||||
<a
|
||||
href={logo.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="logo-marquee-link"
|
||||
>
|
||||
<img
|
||||
src={logo.src}
|
||||
alt=""
|
||||
className="logo-marquee-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<img
|
||||
src={logo.src}
|
||||
alt=""
|
||||
className="logo-marquee-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import PostList from "../components/PostList";
|
||||
import FeaturedCards from "../components/FeaturedCards";
|
||||
import LogoMarquee, {
|
||||
LogoGalleryConfig,
|
||||
LogoItem,
|
||||
} from "../components/LogoMarquee";
|
||||
|
||||
// Site configuration - customize this for your site
|
||||
// All configurable options in one place for easy developer experience
|
||||
const siteConfig = {
|
||||
// Basic site info
|
||||
name: 'markdown "sync" site',
|
||||
title: "Real-time Site with Convex",
|
||||
// Optional logo/header image (place in public/images/, set to null to hide)
|
||||
@@ -23,11 +31,46 @@ const siteConfig = {
|
||||
</>
|
||||
),
|
||||
bio: `Write in markdown, sync to a real-time database, and deploy in minutes. Every time you sync new posts, they appear immediately without redeploying. Built with React, TypeScript, and Convex for instant updates.`,
|
||||
featuredEssays: [
|
||||
{ title: "Setup Guide", slug: "setup-guide" },
|
||||
{ title: "How to Publish", slug: "how-to-publish" },
|
||||
{ title: "About This Site", slug: "about-this-blog" },
|
||||
],
|
||||
|
||||
// Featured section configuration
|
||||
// viewMode: 'list' shows bullet list, 'cards' shows card grid with excerpts
|
||||
featuredViewMode: "list" as "cards" | "list",
|
||||
// Allow users to toggle between list and card views
|
||||
showViewToggle: true,
|
||||
|
||||
// Logo gallery configuration
|
||||
// Set enabled to false to hide, or remove/replace sample images with your own
|
||||
logoGallery: {
|
||||
enabled: true, // Set to false to hide the logo gallery
|
||||
images: [
|
||||
// Sample logos with links (replace with your own)
|
||||
// Each logo can have: { src: "/images/logos/logo.svg", href: "https://example.com" }
|
||||
{
|
||||
src: "/images/logos/sample-logo-1.svg",
|
||||
href: "https://markdowncms.netlify.app/",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/sample-logo-2.svg",
|
||||
href: "https://markdowncms.netlify.app/",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/sample-logo-3.svg",
|
||||
href: "https://markdowncms.netlify.app/",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/sample-logo-4.svg",
|
||||
href: "https://markdowncms.netlify.app/",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/sample-logo-5.svg",
|
||||
href: "https://markdowncms.netlify.app/",
|
||||
},
|
||||
] as LogoItem[],
|
||||
position: "above-footer", // 'above-footer' or 'below-featured'
|
||||
speed: 30, // Seconds for one complete scroll cycle
|
||||
title: "Trusted by", // Optional title above the marquee (set to undefined to hide)
|
||||
} as LogoGalleryConfig,
|
||||
|
||||
// Links for footer section
|
||||
links: {
|
||||
docs: "/setup-guide",
|
||||
@@ -36,10 +79,71 @@ const siteConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
// Local storage key for view mode preference
|
||||
const VIEW_MODE_KEY = "featured-view-mode";
|
||||
|
||||
export default function Home() {
|
||||
// Fetch published posts from Convex
|
||||
const posts = useQuery(api.posts.getAllPosts);
|
||||
|
||||
// Fetch featured posts and pages from Convex (for list view)
|
||||
const featuredPosts = useQuery(api.posts.getFeaturedPosts);
|
||||
const featuredPages = useQuery(api.pages.getFeaturedPages);
|
||||
|
||||
// State for view mode toggle (list or cards)
|
||||
const [viewMode, setViewMode] = useState<"list" | "cards">(
|
||||
siteConfig.featuredViewMode,
|
||||
);
|
||||
|
||||
// Load saved view mode preference from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(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(VIEW_MODE_KEY, newMode);
|
||||
};
|
||||
|
||||
// Render logo gallery based on position config
|
||||
const renderLogoGallery = (position: "above-footer" | "below-featured") => {
|
||||
if (siteConfig.logoGallery.position === position) {
|
||||
return <LogoMarquee config={siteConfig.logoGallery} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Build featured list for list view from Convex data
|
||||
const getFeaturedList = () => {
|
||||
if (featuredPosts === undefined || featuredPages === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Combine posts and pages, sort by featuredOrder
|
||||
const combined = [
|
||||
...featuredPosts.map((p) => ({
|
||||
title: p.title,
|
||||
slug: p.slug,
|
||||
featuredOrder: p.featuredOrder ?? 999,
|
||||
})),
|
||||
...featuredPages.map((p) => ({
|
||||
title: p.title,
|
||||
slug: p.slug,
|
||||
featuredOrder: p.featuredOrder ?? 999,
|
||||
})),
|
||||
];
|
||||
|
||||
return combined.sort((a, b) => a.featuredOrder - b.featuredOrder);
|
||||
};
|
||||
|
||||
const featuredList = getFeaturedList();
|
||||
const hasFeaturedContent = featuredList.length > 0;
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
{/* Header section with intro */}
|
||||
@@ -58,21 +162,77 @@ export default function Home() {
|
||||
|
||||
<p className="home-bio">{siteConfig.bio}</p>
|
||||
|
||||
{/* Featured essays section */}
|
||||
<div className="home-featured">
|
||||
<p className="home-featured-intro">Get started:</p>
|
||||
<ul className="home-featured-list">
|
||||
{siteConfig.featuredEssays.map((essay) => (
|
||||
<li key={essay.slug}>
|
||||
<a href={`/${essay.slug}`} className="home-featured-link">
|
||||
{essay.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Featured section with optional view toggle */}
|
||||
{hasFeaturedContent && (
|
||||
<div className="home-featured">
|
||||
<div className="home-featured-header">
|
||||
<p className="home-featured-intro">Get started:</p>
|
||||
{siteConfig.showViewToggle && (
|
||||
<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>
|
||||
|
||||
{/* Render list or card view based on mode */}
|
||||
{viewMode === "list" ? (
|
||||
<ul className="home-featured-list">
|
||||
{featuredList.map((item) => (
|
||||
<li key={item.slug}>
|
||||
<a href={`/${item.slug}`} className="home-featured-link">
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<FeaturedCards useFrontmatter={true} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Logo gallery (below-featured position) */}
|
||||
{renderLogoGallery("below-featured")}
|
||||
|
||||
{/* Blog posts section - no loading state to avoid flash (Convex syncs instantly) */}
|
||||
<section id="posts" className="home-posts">
|
||||
{posts === undefined ? null : posts.length === 0 ? (
|
||||
@@ -82,6 +242,9 @@ export default function Home() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Logo gallery (above-footer position) */}
|
||||
{renderLogoGallery("above-footer")}
|
||||
|
||||
{/* Footer section */}
|
||||
<section className="home-footer">
|
||||
<p className="home-footer-text">
|
||||
|
||||
@@ -1512,3 +1512,255 @@ body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Featured section header with toggle */
|
||||
.home-featured-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.home-featured-header .home-featured-intro {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* View toggle button */
|
||||
.view-toggle-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.view-toggle-button:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Featured cards grid */
|
||||
.featured-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.featured-card {
|
||||
display: block;
|
||||
padding: 20px;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.featured-card:hover {
|
||||
background-color: var(--bg-hover);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.featured-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.featured-card-excerpt {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Logo marquee container */
|
||||
.logo-marquee-container {
|
||||
margin: 48px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo-marquee-title {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.logo-marquee {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 10%,
|
||||
black 90%,
|
||||
transparent
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 10%,
|
||||
black 90%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.logo-marquee-track {
|
||||
display: flex;
|
||||
animation: marquee-scroll var(--marquee-speed, 30s) linear infinite;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
@keyframes marquee-scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-marquee-item {
|
||||
flex-shrink: 0;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-marquee-image {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
max-width: 120px;
|
||||
object-fit: contain;
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.logo-marquee-image:hover {
|
||||
filter: grayscale(0%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.logo-marquee-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-marquee-link:hover .logo-marquee-image {
|
||||
filter: grayscale(0%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments for featured cards */
|
||||
:root[data-theme="dark"] .featured-card {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .featured-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;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .logo-marquee-image:hover {
|
||||
filter: invert(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .logo-marquee-link:hover .logo-marquee-image {
|
||||
filter: invert(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Tan theme adjustments for featured cards */
|
||||
:root[data-theme="tan"] .featured-card {
|
||||
box-shadow: 0 2px 8px rgba(139, 115, 85, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme="tan"] .featured-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);
|
||||
}
|
||||
|
||||
:root[data-theme="cloud"] .featured-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Featured cards responsive */
|
||||
@media (max-width: 768px) {
|
||||
.featured-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.featured-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.featured-card-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.featured-card-excerpt {
|
||||
font-size: 13px;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.logo-marquee-item {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.logo-marquee-image {
|
||||
height: 28px;
|
||||
max-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.featured-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.view-toggle-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.logo-marquee-item {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.logo-marquee-image {
|
||||
height: 24px;
|
||||
max-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user