From ac0dfab78481fa4e401a71d2597cc972401cac00 Mon Sep 17 00:00:00 2001 From: Wayne Sutton Date: Tue, 30 Dec 2025 15:22:46 -0800 Subject: [PATCH] feat: add unlisted frontmatter field to hide posts from listings while keeping direct access --- .claude/skills/frontmatter.md | 10 + changelog.md | 12 ++ content/blog/Untitled | 2 + .../blog/how-i-added-workos-with-cursor.md | 194 ++++++++++++++++++ content/blog/setup-guide.md | 1 + content/pages/changelog-page.md | 14 +- content/pages/docs.md | 4 +- convex/posts.ts | 30 ++- convex/schema.ts | 1 + convex/search.ts | 3 + files.md | 1 + scripts/sync-posts.ts | 2 + src/pages/Write.tsx | 1 + 13 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 content/blog/Untitled create mode 100644 content/blog/how-i-added-workos-with-cursor.md diff --git a/.claude/skills/frontmatter.md b/.claude/skills/frontmatter.md index c4d29d8..cf7058d 100644 --- a/.claude/skills/frontmatter.md +++ b/.claude/skills/frontmatter.md @@ -35,6 +35,7 @@ Location: `content/blog/*.md` | blogFeatured | boolean | false | Hero featured on /blog page | | newsletter | boolean | - | Override newsletter signup | | contactForm | boolean | false | Enable contact form | +| unlisted | boolean | false | Hide from listings but allow direct access via slug | | showFooter | boolean | - | Override footer display | | footer | string | - | Custom footer markdown | | showSocialFooter | boolean | - | Override social footer | @@ -141,6 +142,15 @@ rightSidebar: true showInNav: false ``` +### Unlisted post (hidden from listings) + +```yaml +published: true +unlisted: true +``` + +Post remains accessible via direct link but hidden from all listings, search, and related posts. + ### Enable contact form ```yaml diff --git a/changelog.md b/changelog.md index 7291f35..f001fe9 100644 --- a/changelog.md +++ b/changelog.md @@ -21,12 +21,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Automated CLAUDE.md updates via sync-discovery-files.ts - CLAUDE.md status comment updated during `npm run sync:discovery` - Includes current site name, post count, page count, and last updated timestamp +- Unlisted posts feature + - New `unlisted` frontmatter field for blog posts + - Set `unlisted: true` to hide posts from listings while keeping them accessible via direct link + - Unlisted posts are excluded from: blog listings, featured sections, tag pages, search results, and related posts + - Posts remain accessible via direct URL (e.g., `/blog/post-slug`) + - Useful for draft posts, private content, or posts you want to share via direct link only ### Technical - New file: `CLAUDE.md` in project root - New directory: `.claude/skills/` with three markdown files - Updated: `scripts/sync-discovery-files.ts` to update CLAUDE.md alongside AGENTS.md and llms.txt +- Updated: `convex/schema.ts` - Added `unlisted` optional boolean field to posts table +- Updated: `convex/posts.ts` - All listing queries filter out unlisted posts (getAllPosts, getBlogFeaturedPosts, getFeaturedPosts, getAllTags, getPostsByTag, getRelatedPosts, getRecentPostsInternal) +- Updated: `convex/search.ts` - Search excludes unlisted posts from results +- Updated: `scripts/sync-posts.ts` - Added `unlisted` to PostFrontmatter and ParsedPost interfaces and parsing logic +- Updated: `src/pages/Write.tsx` - Added `unlisted` to POST_FIELDS frontmatter reference +- Updated documentation: `.claude/skills/frontmatter.md`, `content/pages/docs.md`, `content/blog/setup-guide.md`, `files.md` ## [2.0.0] - 2025-12-29 diff --git a/content/blog/Untitled b/content/blog/Untitled new file mode 100644 index 0000000..c2cc62f --- /dev/null +++ b/content/blog/Untitled @@ -0,0 +1,2 @@ +published: true +unlisted: true \ No newline at end of file diff --git a/content/blog/how-i-added-workos-with-cursor.md b/content/blog/how-i-added-workos-with-cursor.md new file mode 100644 index 0000000..c082855 --- /dev/null +++ b/content/blog/how-i-added-workos-with-cursor.md @@ -0,0 +1,194 @@ +--- +title: "How I added WorkOS to my Convex app with Cursor" +description: "A timeline of adding WorkOS AuthKit authentication to my markdown blog dashboard using Cursor, prompt engineering, and vibe coding. From PRD import to published feature." +date: "2025-12-30" +slug: "how-i-added-workos-with-cursor" +tags: ["cursor", "workos", "convex", "prompt-engineering", "ai-coding"] +readTime: "8 min read" +featured: false +featuredOrder: 5 +published: true +unlisted: true +layout: "sidebar" +excerpt: "How I used Cursor, prompt engineering, and Claude to add WorkOS authentication to my Convex dashboard. A real timeline from PRD import to published feature." +--- + +# How I added WorkOS to my Convex app with Cursor + +I added WorkOS AuthKit authentication to my markdown blog dashboard using Cursor, prompt engineering, and what I call vibe coding. Here's the timeline from start to published. + +## The goal + +Add optional WorkOS authentication to the `/dashboard` page. The dashboard should work with or without WorkOS configured. When authentication is enabled, users log in before accessing the dashboard. + +## Timeline: start to published + +### Step 1: Updated docs and cursor rules + +I started by updating `content/pages/docs.md` with a dashboard section explaining how it works. Then I updated my Cursor rules to include WorkOS documentation patterns and Convex AuthKit integration guidelines. + +The docs update gave me a clear picture of what I wanted to build. The cursor rules helped Cursor understand the patterns I use for authentication in Convex apps. + +### Step 2: Imported PRD from Claude + +I had a conversation with Claude about adding WorkOS to a Convex app. Claude generated a step-by-step PRD that covered: + +- WorkOS account setup +- Environment variable configuration +- Convex auth configuration +- React component structure +- Route handling + +I imported this PRD into my project at `prds/workos-authkit-dashboard-guide.md`. It became the blueprint for the entire feature. + +### Step 3: Created a plan in Cursor + +I opened Cursor and asked it to create a plan based on the PRD. Cursor analyzed the PRD and generated a structured plan file at `.cursor/plans/workos_setup_9603c983.plan.md`. + +The plan broke down the work into specific tasks: + +- Create Callback.tsx +- Add callback route to App.tsx +- Update Dashboard.tsx with auth protection +- Test the authentication flow + +Each task had a clear status and description. This gave me a roadmap I could follow step by step. + +### Step 4: Updated environment variables + +I added WorkOS environment variables to `.env.local`: + +```env +VITE_WORKOS_CLIENT_ID=client_01XXXXXXXXXXXXXXXXX +VITE_WORKOS_REDIRECT_URI=http://localhost:5173/callback +``` + +I also added `WORKOS_CLIENT_ID` to my Convex environment variables through the Convex dashboard. These variables connect the frontend and backend to WorkOS. + +### Step 5: Cursor and Opus built the app + +I started implementing the plan with Cursor. I asked it to create the Callback component first. Cursor generated `src/pages/Callback.tsx` with proper WorkOS auth handling. + +Then I asked Cursor to update `src/App.tsx` to add the callback route. It added the route correctly, matching the existing route patterns. + +For the Dashboard component, I asked Cursor to add authentication protection. It wrapped the dashboard content with `Authenticated`, `Unauthenticated`, and `AuthLoading` components from Convex React. + +### Step 6: Debugged routes for dashboard page + +The dashboard needed to work in two modes: + +1. With WorkOS configured and `requireAuth: true` - requires login +2. Without WorkOS or with `requireAuth: false` - open access + +I asked Cursor to implement conditional authentication. It created a utility function `isWorkOSConfigured()` that checks if WorkOS environment variables are set. + +The Dashboard component now checks: + +- If dashboard is disabled, show disabled message +- If auth is required but WorkOS isn't configured, show setup instructions +- If WorkOS isn't configured and auth isn't required, show dashboard directly +- If WorkOS is configured, use the auth flow + +This conditional logic ensures the dashboard works whether WorkOS is set up or not. + +### Step 7: Frontmatter integration + +I wanted the dashboard authentication to be configurable via `siteConfig.ts`. I added a `dashboard` configuration object: + +```typescript +dashboard: { + enabled: true, + requireAuth: false, // Set to true to require WorkOS authentication +}, +``` + +Cursor helped me integrate this configuration into the Dashboard component. The component reads `siteConfig.dashboard.enabled` and `siteConfig.dashboard.requireAuth` to determine behavior. + +### Step 8: Published and documented + +After testing locally, I: + +1. Deployed the changes to production +2. Updated `changelog.md` with the new feature +3. Updated `content/pages/changelog-page.md` with release notes +4. Created a blog post: "How to setup WorkOS" +5. Updated `files.md` with new file descriptions + +The feature went live on December 29, 2025 as part of v1.45.0. + +## What I learned about prompt engineering + +### Start with documentation + +Updating docs first helped me clarify what I wanted to build. When I asked Cursor to implement features, it had context from the docs to understand the patterns. + +### Use PRDs as blueprints + +The PRD from Claude became the single source of truth. Every implementation step referenced the PRD. This kept the work focused and prevented scope creep. + +### Break work into small tasks + +The plan file broke the work into specific, actionable tasks. Each task was small enough that Cursor could complete it in one go. This made progress visible and debugging easier. + +### Iterate on prompts + +When Cursor didn't get something right, I refined my prompts. Instead of "add authentication," I said "add WorkOS authentication that works with or without WorkOS configured, checking siteConfig.dashboard.requireAuth." + +Specific prompts led to better results. + +### Trust but verify + +Cursor generated working code, but I tested each change. The conditional authentication logic needed manual verification to ensure it handled all cases correctly. + +## The vibe coding experience + +Vibe coding means working with AI tools in a flow state. You describe what you want, the AI generates code, you test it, and you iterate. + +With Cursor, this felt natural: + +1. I described the feature +2. Cursor generated the code +3. I tested it locally +4. I asked for refinements +5. We repeated until it worked + +The back-and-forth felt like pair programming with a very fast partner who never gets tired. + +## Key files created + +- `src/pages/Callback.tsx` - Handles OAuth callback +- `src/utils/workos.ts` - WorkOS configuration utility +- `convex/auth.config.ts` - Convex auth configuration +- `prds/workos-authkit-dashboard-guide.md` - Step-by-step PRD +- `.cursor/plans/workos_setup_9603c983.plan.md` - Implementation plan + +## Key files modified + +- `src/main.tsx` - Added conditional WorkOS providers +- `src/App.tsx` - Added callback route handling +- `src/pages/Dashboard.tsx` - Added optional authentication +- `src/config/siteConfig.ts` - Added dashboard configuration +- `content/pages/docs.md` - Added dashboard documentation + +## Result + +The dashboard now supports optional WorkOS authentication. Users can: + +- Use the dashboard without WorkOS (open access) +- Enable WorkOS authentication via `siteConfig.dashboard.requireAuth` +- See setup instructions if auth is required but WorkOS isn't configured + +The implementation is clean, type-safe, and follows Convex best practices. It works whether WorkOS is configured or not. + +## Takeaways + +1. **Documentation first** - Writing docs clarifies requirements +2. **PRDs as blueprints** - Import PRDs to guide implementation +3. **Small tasks** - Break work into specific, actionable items +4. **Specific prompts** - Detailed prompts produce better code +5. **Test everything** - Verify AI-generated code works correctly +6. **Iterate quickly** - Use AI tools to move fast and refine + +Adding WorkOS to my Convex app took a few hours from start to published. Most of that time was testing and refining. The actual coding was fast thanks to Cursor and good prompt engineering. + +The [How to setup WorkOS](https://www.markdown.fast/how-to-setup-workos) is the setup guide covers everything you need to get started diff --git a/content/blog/setup-guide.md b/content/blog/setup-guide.md index aaeb193..7729966 100644 --- a/content/blog/setup-guide.md +++ b/content/blog/setup-guide.md @@ -352,6 +352,7 @@ Your markdown content here... | `authorName` | No | Author display name shown next to date | | `authorImage` | No | Round author avatar image URL | | `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | ### How Frontmatter Works diff --git a/content/pages/changelog-page.md b/content/pages/changelog-page.md index cbfb0ff..ceca37f 100644 --- a/content/pages/changelog-page.md +++ b/content/pages/changelog-page.md @@ -29,14 +29,26 @@ Released December 30, 2025 - Automated CLAUDE.md updates via sync-discovery-files.ts - CLAUDE.md status comment updated during `npm run sync:discovery` - Includes current site name, post count, page count, and last updated timestamp +- Unlisted posts feature + - New `unlisted` frontmatter field for blog posts + - Set `unlisted: true` to hide posts from listings while keeping them accessible via direct link + - Unlisted posts are excluded from: blog listings, featured sections, tag pages, search results, and related posts + - Posts remain accessible via direct URL (e.g., `/blog/post-slug`) + - Useful for draft posts, private content, or posts you want to share via direct link only **Technical details:** - New file: `CLAUDE.md` in project root - New directory: `.claude/skills/` with three markdown files - Updated: `scripts/sync-discovery-files.ts` to update CLAUDE.md alongside AGENTS.md and llms.txt +- Updated: `convex/schema.ts` - Added `unlisted` optional boolean field to posts table +- Updated: `convex/posts.ts` - All listing queries filter out unlisted posts +- Updated: `convex/search.ts` - Search excludes unlisted posts from results +- Updated: `scripts/sync-posts.ts` - Added `unlisted` to interfaces and parsing logic +- Updated: `src/pages/Write.tsx` - Added `unlisted` to POST_FIELDS frontmatter reference +- Updated documentation: `.claude/skills/frontmatter.md`, `content/pages/docs.md`, `content/blog/setup-guide.md`, `files.md` -Updated files: `CLAUDE.md`, `.claude/skills/frontmatter.md`, `.claude/skills/convex.md`, `.claude/skills/sync.md`, `scripts/sync-discovery-files.ts`, `files.md`, `changelog.md`, `TASK.md` +Updated files: `CLAUDE.md`, `.claude/skills/frontmatter.md`, `.claude/skills/convex.md`, `.claude/skills/sync.md`, `scripts/sync-discovery-files.ts`, `convex/schema.ts`, `convex/posts.ts`, `convex/search.ts`, `scripts/sync-posts.ts`, `src/pages/Write.tsx`, `files.md`, `changelog.md`, `content/pages/changelog-page.md` ## v2.0.0 diff --git a/content/pages/docs.md b/content/pages/docs.md index 0b2b394..6301bdf 100644 --- a/content/pages/docs.md +++ b/content/pages/docs.md @@ -4,7 +4,6 @@ slug: "docs" published: true order: 0 layout: "sidebar" -rightSidebar: true aiChat: true showFooter: true --- @@ -129,6 +128,7 @@ Content here... | `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) | | `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | | `contactForm` | No | Enable contact form on this post | +| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | | `showImageAtTop` | No | Set `true` to display the `image` field at the top of the post above the header (default: `false`) | ### Static pages @@ -173,6 +173,8 @@ Content here... **Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav. +**Unlisted posts:** Set `unlisted: true` to hide a blog post from all listings while keeping it accessible via direct link. Unlisted posts are excluded from: blog listings (`/blog` page), featured sections (homepage), tag pages (`/tags/[tag]`), search results (Command+K), and related posts. The post remains accessible via direct URL (e.g., `/blog/post-slug`). Useful for draft posts, private content, or posts you want to share via direct link only. Note: `unlisted` only works for blog posts, not pages. + **Show image at top:** Add `showImageAtTop: true` to display the `image` field at the top of the post/page above the header. Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails. **Image lightbox:** Images in blog posts and pages automatically open in a full-screen lightbox when clicked (if enabled in `siteConfig.imageLightbox.enabled`). This allows readers to view images at full size. The lightbox can be closed by clicking outside the image, pressing Escape, or clicking the close button. diff --git a/convex/posts.ts b/convex/posts.ts index 0e55e7b..70228e0 100644 --- a/convex/posts.ts +++ b/convex/posts.ts @@ -86,8 +86,11 @@ export const getAllPosts = query({ .withIndex("by_published", (q) => q.eq("published", true)) .collect(); + // Filter out unlisted posts + const listedPosts = posts.filter((p) => !p.unlisted); + // Sort by date descending - const sortedPosts = posts.sort( + const sortedPosts = listedPosts.sort( (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), ); @@ -143,7 +146,7 @@ export const getBlogFeaturedPosts = query({ // Filter to only published posts and sort by date descending const publishedFeatured = posts - .filter((p) => p.published) + .filter((p) => p.published && !p.unlisted) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return publishedFeatured.map((post) => ({ @@ -184,7 +187,7 @@ export const getFeaturedPosts = query({ // Filter to only published posts and sort by featuredOrder const featuredPosts = posts - .filter((p) => p.published) + .filter((p) => p.published && !p.unlisted) .sort((a, b) => { const orderA = a.featuredOrder ?? 999; const orderB = b.featuredOrder ?? 999; @@ -335,9 +338,9 @@ export const getRecentPostsInternal = internalQuery({ .withIndex("by_published", (q) => q.eq("published", true)) .collect(); - // Filter posts by date and sort descending + // Filter posts by date and sort descending, excluding unlisted const recentPosts = posts - .filter((post) => post.date >= args.since) + .filter((post) => post.date >= args.since && !post.unlisted) .sort((a, b) => b.date.localeCompare(a.date)) .map((post) => ({ slug: post.slug, @@ -617,9 +620,12 @@ export const getAllTags = query({ .withIndex("by_published", (q) => q.eq("published", true)) .collect(); + // Filter out unlisted posts + const listedPosts = posts.filter((p) => !p.unlisted); + // Count occurrences of each tag const tagCounts = new Map(); - for (const post of posts) { + for (const post of listedPosts) { for (const tag of post.tags) { tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); } @@ -665,9 +671,11 @@ export const getPostsByTag = query({ .withIndex("by_published", (q) => q.eq("published", true)) .collect(); - // Filter posts that have the specified tag - const filteredPosts = posts.filter((post) => - post.tags.some((t) => t.toLowerCase() === args.tag.toLowerCase()), + // Filter posts that have the specified tag and are not unlisted + const filteredPosts = posts.filter( + (post) => + !post.unlisted && + post.tags.some((t) => t.toLowerCase() === args.tag.toLowerCase()), ); // Sort by date descending @@ -728,9 +736,9 @@ export const getRelatedPosts = query({ .withIndex("by_published", (q) => q.eq("published", true)) .collect(); - // Find posts that share tags, excluding current post + // Find posts that share tags, excluding current post and unlisted posts const relatedPosts = posts - .filter((post) => post.slug !== args.currentSlug) + .filter((post) => post.slug !== args.currentSlug && !post.unlisted) .map((post) => { const sharedTags = post.tags.filter((tag) => args.tags.some((t) => t.toLowerCase() === tag.toLowerCase()), diff --git a/convex/schema.ts b/convex/schema.ts index 57fc0e8..fc7939a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -28,6 +28,7 @@ export default defineSchema({ blogFeatured: v.optional(v.boolean()), // Show as hero featured post on /blog page newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false) contactForm: v.optional(v.boolean()), // Enable contact form on this post + unlisted: v.optional(v.boolean()), // Hide from listings but allow direct access via slug lastSyncedAt: v.number(), }) .index("by_slug", ["slug"]) diff --git a/convex/search.ts b/convex/search.ts index c6fe9a9..6d4004a 100644 --- a/convex/search.ts +++ b/convex/search.ts @@ -72,6 +72,9 @@ export const search = query({ if (seenPostIds.has(post._id)) continue; seenPostIds.add(post._id); + // Skip unlisted posts + if (post.unlisted) continue; + // Create snippet from content and find anchor const { snippet, anchor } = createSnippet(post.content, args.query, 120); diff --git a/files.md b/files.md index c33ad7c..db4805f 100644 --- a/files.md +++ b/files.md @@ -169,6 +169,7 @@ Markdown files with frontmatter for blog posts. Each file becomes a blog post. | `blogFeatured` | Show as featured on blog page (optional, first becomes hero, rest in 2-column row) | | `newsletter` | Override newsletter signup display (optional, true/false) | | `contactForm` | Enable contact form on this post (optional). Requires siteConfig.contactForm.enabled: true and AGENTMAIL_API_KEY/AGENTMAIL_INBOX environment variables. | +| `unlisted` | Hide from listings but allow direct access via slug (optional). Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | ## Static Pages (`content/pages/`) diff --git a/scripts/sync-posts.ts b/scripts/sync-posts.ts index b51757f..29a080b 100644 --- a/scripts/sync-posts.ts +++ b/scripts/sync-posts.ts @@ -73,6 +73,7 @@ interface ParsedPost { blogFeatured?: boolean; // Show as hero featured post on /blog page newsletter?: boolean; // Override newsletter signup display (true/false) contactForm?: boolean; // Enable contact form on this post + unlisted?: boolean; // Hide from listings but allow direct access via slug } // Page frontmatter (for static pages like About, Projects, Contact) @@ -172,6 +173,7 @@ function parseMarkdownFile(filePath: string): ParsedPost | null { blogFeatured: frontmatter.blogFeatured, // Show as hero featured post on /blog page newsletter: frontmatter.newsletter, // Override newsletter signup display contactForm: frontmatter.contactForm, // Enable contact form on this post + unlisted: frontmatter.unlisted, // Hide from listings but allow direct access }; } catch (error) { console.error(`Error parsing ${filePath}:`, error); diff --git a/src/pages/Write.tsx b/src/pages/Write.tsx index 5dc4039..658af04 100644 --- a/src/pages/Write.tsx +++ b/src/pages/Write.tsx @@ -59,6 +59,7 @@ const POST_FIELDS = [ { name: "blogFeatured", required: false, example: "true" }, { name: "newsletter", required: false, example: "true" }, { name: "contactForm", required: false, example: "true" }, + { name: "unlisted", required: false, example: "true" }, ]; // Frontmatter field definitions for pages