From 87e02d00dc9f8bea70bec6e01f2306be2fc94730 Mon Sep 17 00:00:00 2001 From: Wayne Sutton Date: Thu, 18 Dec 2025 12:28:25 -0800 Subject: [PATCH] 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 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 --- AGENTS.md | 397 ++++++++++++++++++ README.md | 165 +++++++- TASK.md | 6 +- changelog.md | 98 +++++ content/blog/about-this-blog.md | 5 + content/blog/how-to-publish.md | 108 ++++- .../new-features-search-featured-logos.md | 92 ++++ content/blog/setup-guide.md | 234 ++++++++++- content/pages/about.md | 12 + content/pages/changelog-page.md | 160 +++++++ content/pages/docs.md | 208 +++++++-- convex/http.ts | 45 ++ convex/pages.ts | 55 +++ convex/posts.ts | 63 +++ convex/schema.ts | 8 + files.md | 131 +++--- netlify.toml | 4 + netlify/edge-functions/api.ts | 2 +- package-lock.json | 354 ++++++++++++++++ package.json | 2 + public/.well-known/ai-plugin.json | 18 + public/images/logos/sample-logo-1.svg | 5 + public/images/logos/sample-logo-2.svg | 5 + public/images/logos/sample-logo-3.svg | 5 + public/images/logos/sample-logo-4.svg | 6 + public/images/logos/sample-logo-5.svg | 5 + public/llms.txt | 80 +++- public/openapi.yaml | 195 +++++++++ scripts/import-url.ts | 152 +++++++ scripts/sync-posts.ts | 18 + src/components/FeaturedCards.tsx | 142 +++++++ src/components/LogoMarquee.tsx | 84 ++++ src/pages/Home.tsx | 199 ++++++++- src/styles/global.css | 252 +++++++++++ 34 files changed, 3161 insertions(+), 154 deletions(-) create mode 100644 AGENTS.md create mode 100644 content/blog/new-features-search-featured-logos.md create mode 100644 content/pages/changelog-page.md create mode 100644 public/.well-known/ai-plugin.json create mode 100644 public/images/logos/sample-logo-1.svg create mode 100644 public/images/logos/sample-logo-2.svg create mode 100644 public/images/logos/sample-logo-3.svg create mode 100644 public/images/logos/sample-logo-4.svg create mode 100644 public/images/logos/sample-logo-5.svg create mode 100644 public/openapi.yaml create mode 100644 scripts/import-url.ts create mode 100644 src/components/FeaturedCards.tsx create mode 100644 src/components/LogoMarquee.tsx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..256424d --- /dev/null +++ b/AGENTS.md @@ -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 # 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) + diff --git a/README.md b/README.md index 89ec86f..1f5facf 100644 --- a/README.md +++ b/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 ` 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 diff --git a/TASK.md b/TASK.md index 8749f97..ee140d0 100644 --- a/TASK.md +++ b/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 diff --git a/changelog.md b/changelog.md index be78570..f1fceea 100644 --- a/changelog.md +++ b/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 ` 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 diff --git a/content/blog/about-this-blog.md b/content/blog/about-this-blog.md index 8aa0011..7fa34ef 100644 --- a/content/blog/about-this-blog.md +++ b/content/blog/about-this-blog.md @@ -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 ` 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 diff --git a/content/blog/how-to-publish.md b/content/blog/how-to-publish.md index 74f53ef..d47f868 100644 --- a/content/blog/how-to-publish.md +++ b/content/blog/how-to-publish.md @@ -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: diff --git a/content/blog/new-features-search-featured-logos.md b/content/blog/new-features-search-featured-logos.md new file mode 100644 index 0000000..01ee7f6 --- /dev/null +++ b/content/blog/new-features-search-featured-logos.md @@ -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. diff --git a/content/blog/setup-guide.md b/content/blog/setup-guide.md index b7cd5f6..4b6d56f 100644 --- a/content/blog/setup-guide.md +++ b/content/blog/setup-guide.md @@ -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 diff --git a/content/pages/about.md b/content/pages/about.md index e0e71ff..9204b3c 100644 --- a/content/pages/about.md +++ b/content/pages/about.md @@ -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. diff --git a/content/pages/changelog-page.md b/content/pages/changelog-page.md new file mode 100644 index 0000000..7ef34fa --- /dev/null +++ b/content/pages/changelog-page.md @@ -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 ` 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 + diff --git a/content/pages/docs.md b/content/pages/docs.md index 6a7b9f9..dc611ac 100644 --- a/content/pages/docs.md +++ b/content/pages/docs.md @@ -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"]), }); ``` diff --git a/convex/http.ts b/convex/http.ts index 6eedceb..19286eb 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -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 diff --git a/convex/pages.ts b/convex/pages.ts index 8f4fa8f..48f7b88 100644 --- a/convex/pages.ts +++ b/convex/pages.ts @@ -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++; diff --git a/convex/posts.ts b/convex/posts.ts index b6f683e..a58b7b1 100644 --- a/convex/posts.ts +++ b/convex/posts.ts @@ -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++; diff --git a/convex/schema.ts b/convex/schema.ts index a731434..fc38e1f 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"], diff --git a/files.md b/files.md index f6a54ae..c905805 100644 --- a/files.md +++ b/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) | diff --git a/netlify.toml b/netlify.toml index 9f1cb9f..b1a627c 100644 --- a/netlify.toml +++ b/netlify.toml @@ -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 = "/*" diff --git a/netlify/edge-functions/api.ts b/netlify/edge-functions/api.ts index f25feb5..a3d6c17 100644 --- a/netlify/edge-functions/api.ts +++ b/netlify/edge-functions/api.ts @@ -57,5 +57,5 @@ export default async function handler( } export const config = { - path: ["/api/posts", "/api/post"], + path: ["/api/posts", "/api/post", "/api/export"], }; diff --git a/package-lock.json b/package-lock.json index 0ab66e2..8d276b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index bdbdbf1..52ddf1e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/.well-known/ai-plugin.json b/public/.well-known/ai-plugin.json new file mode 100644 index 0000000..a4a0b80 --- /dev/null +++ b/public/.well-known/ai-plugin.json @@ -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": "" +} + diff --git a/public/images/logos/sample-logo-1.svg b/public/images/logos/sample-logo-1.svg new file mode 100644 index 0000000..f2ca1f9 --- /dev/null +++ b/public/images/logos/sample-logo-1.svg @@ -0,0 +1,5 @@ + + + Acme + + diff --git a/public/images/logos/sample-logo-2.svg b/public/images/logos/sample-logo-2.svg new file mode 100644 index 0000000..22dfbb8 --- /dev/null +++ b/public/images/logos/sample-logo-2.svg @@ -0,0 +1,5 @@ + + + Vertex + + diff --git a/public/images/logos/sample-logo-3.svg b/public/images/logos/sample-logo-3.svg new file mode 100644 index 0000000..1c9d089 --- /dev/null +++ b/public/images/logos/sample-logo-3.svg @@ -0,0 +1,5 @@ + + + Delta + + diff --git a/public/images/logos/sample-logo-4.svg b/public/images/logos/sample-logo-4.svg new file mode 100644 index 0000000..8189a93 --- /dev/null +++ b/public/images/logos/sample-logo-4.svg @@ -0,0 +1,6 @@ + + + + Pulse + + diff --git a/public/images/logos/sample-logo-5.svg b/public/images/logos/sample-logo-5.svg new file mode 100644 index 0000000..5364211 --- /dev/null +++ b/public/images/logos/sample-logo-5.svg @@ -0,0 +1,5 @@ + + + Nova + + diff --git a/public/llms.txt b/public/llms.txt index 22f87d9..606d239 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -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 diff --git a/public/openapi.yaml b/public/openapi.yaml new file mode 100644 index 0000000..1515874 --- /dev/null +++ b/public/openapi.yaml @@ -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 + diff --git a/scripts/import-url.ts b/scripts/import-url.ts new file mode 100644 index 0000000..c695d2b --- /dev/null +++ b/scripts/import-url.ts @@ -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 \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); + diff --git a/scripts/sync-posts.ts b/scripts/sync-posts.ts index 0d9ac40..700a343 100644 --- a/scripts/sync-posts.ts +++ b/scripts/sync-posts.ts @@ -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); diff --git a/src/components/FeaturedCards.tsx b/src/components/FeaturedCards.tsx new file mode 100644 index 0000000..c5ca07f --- /dev/null +++ b/src/components/FeaturedCards.tsx @@ -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 ( +
+ {featuredData.map((item) => ( + +

{item.title}

+ {item.excerpt && ( +

{item.excerpt}

+ )} +
+ ))} +
+ ); +} diff --git a/src/components/LogoMarquee.tsx b/src/components/LogoMarquee.tsx new file mode 100644 index 0000000..117cae6 --- /dev/null +++ b/src/components/LogoMarquee.tsx @@ -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 ( +
+ {config.title && ( +

{config.title}

+ )} +
+
+ {duplicatedImages.map((logo, index) => ( +
+ {logo.href ? ( + + + + ) : ( + + )} +
+ ))} +
+
+
+ ); +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index c8ba380..492a895 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 ; + } + 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 (
{/* Header section with intro */} @@ -58,21 +162,77 @@ export default function Home() {

{siteConfig.bio}

- {/* Featured essays section */} -
-

Get started:

- -
+ {/* Featured section with optional view toggle */} + {hasFeaturedContent && ( +
+
+

Get started:

+ {siteConfig.showViewToggle && ( + + )} +
+ + {/* Render list or card view based on mode */} + {viewMode === "list" ? ( + + ) : ( + + )} +
+ )} + {/* Logo gallery (below-featured position) */} + {renderLogoGallery("below-featured")} + {/* Blog posts section - no loading state to avoid flash (Convex syncs instantly) */}
{posts === undefined ? null : posts.length === 0 ? ( @@ -82,6 +242,9 @@ export default function Home() { )}
+ {/* Logo gallery (above-footer position) */} + {renderLogoGallery("above-footer")} + {/* Footer section */}

diff --git a/src/styles/global.css b/src/styles/global.css index 713b3b5..fdf974d 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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; + } +}