feat: add unlisted frontmatter field to hide posts from listings while keeping direct access

This commit is contained in:
Wayne Sutton
2025-12-30 15:22:46 -08:00
parent a5c30a1592
commit ac0dfab784
13 changed files with 262 additions and 13 deletions

View File

@@ -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

View File

@@ -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

2
content/blog/Untitled Normal file
View File

@@ -0,0 +1,2 @@
published: true
unlisted: true

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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<string, number>();
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,8 +671,10 @@ 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) =>
// 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()),
);
@@ -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()),

View File

@@ -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"])

View File

@@ -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);

View File

@@ -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/`)

View File

@@ -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);

View File

@@ -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