Skip to content

feat: publish draft posts (with badge + noindex)#1829

Merged
danielroe merged 4 commits intomainfrom
feat/drafts
Mar 2, 2026
Merged

feat: publish draft posts (with badge + noindex)#1829
danielroe merged 4 commits intomainfrom
feat/drafts

Conversation

@danielroe
Copy link
Member

🔗 Linked issue

🧭 Context

📚 Description

CleanShot 2026-03-02 at 12 53 09@2x

this allows us to preview in prod - whilst also preventing indexing from search engines (and adding a disclaimer)

inspired by https://opensourcepledge.com/blog/npmx-a-lesson-in-open-source-collaboration-feedback-loops/

@vercel
Copy link

vercel bot commented Mar 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 2, 2026 2:35pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 2, 2026 2:35pm
npmx-lunaria Ignored Ignored Mar 2, 2026 2:35pm

Request Review

@danielroe danielroe requested a review from whitep4nth3r March 2, 2026 12:53
@github-actions
Copy link

github-actions bot commented Mar 2, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
i18n/locales/en.json Source changed, localizations will be marked as outdated.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 2, 2026

📝 Walkthrough

Walkthrough

The change adds draft post support across the blog: a new draft boolean prop is exposed on BlogPostListCard to render a draft badge; BlogPostWrapper shows a draft banner and injects robots: 'noindex, nofollow' into SEO metadata for draft posts; the blog loader now returns drafts and listing filtering is controlled by a showDrafts flag, while per-route X-Robots-Tag: noindex, nofollow headers are added for draft post routes. Two i18n keys (blog.draft_badge, blog.draft_banner) and their schema entries were added.

🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly relates to the changeset, explaining that draft posts can now be previewed in production with search engine indexing prevention and a disclaimer badge.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/drafts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@whitep4nth3r
Copy link
Contributor

@danielroe can you provide some context about why we want to show draft posts to everyone in the world please?

Also you'll need to remove the hello world blog post to merge this I think.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2d65a47 and c8d7926.

📒 Files selected for processing (8)
  • app/components/BlogPostListCard.vue
  • app/components/global/BlogPostWrapper.vue
  • app/pages/blog/index.vue
  • i18n/locales/en.json
  • i18n/schema.json
  • lunaria/files/en-GB.json
  • lunaria/files/en-US.json
  • modules/blog.ts

Comment on lines +95 to 104
// Add X-Robots-Tag header for draft posts to prevent indexing
const posts = loadBlogPosts(blogDir)
for (const post of posts) {
if (post.draft) {
nuxt.options.routeRules ||= {}
nuxt.options.routeRules[`/blog/${post.slug}`] = {
headers: { 'X-Robots-Tag': 'noindex, nofollow' },
}
})
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Blog posts with explicit frontmatter path (slug/path mismatch risk):"
rg -n --type=md '^path:\s*' app/pages/blog || true

echo
echo "2) Existing routeRules definitions that could be clobbered:"
rg -n -C2 'routeRules' . || true

Repository: npmx-dev/npmx.dev

Length of output: 1193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Blog post type/interface definition (checking for 'path' property) ==="
rg -n 'interface.*[Pp]ost|type.*[Pp]ost' modules/blog.ts -A 20 | head -50

echo
echo "=== loadBlogPosts function to understand post structure ==="
rg -n 'function loadBlogPosts|const loadBlogPosts' modules/blog.ts -A 30 | head -60

echo
echo "=== Full nuxt.config.ts routeRules to check for blog routes ==="
rg -n 'routeRules:' nuxt.config.ts -A 30

echo
echo "=== Check for post.path usage in modules/blog.ts ==="
rg -n 'post\.path' modules/blog.ts || echo "No post.path found"

Repository: npmx-dev/npmx.dev

Length of output: 4171


Use post.path instead of hardcoded /blog/${post.slug} and merge headers when updating route rules.

The code ignores the normalised post.path property (set at line 26) and hardcodes /blog/${post.slug} instead, which can cause misalignment if posts use custom paths. Additionally, overwriting the entire routeRules entry discards any existing rule fields without merging headers, risking loss of previously configured settings.

💡 Suggested fix
-    for (const post of posts) {
-      if (post.draft) {
-        nuxt.options.routeRules ||= {}
-        nuxt.options.routeRules[`/blog/${post.slug}`] = {
-          headers: { 'X-Robots-Tag': 'noindex, nofollow' },
-        }
-      }
-    }
+    for (const post of posts) {
+      if (!post.draft) continue
+
+      nuxt.options.routeRules ||= {}
+      const routePath = post.path || `/blog/${post.slug}`
+      const existingRule = nuxt.options.routeRules[routePath] ?? {}
+      const existingHeaders
+        = typeof existingRule === 'object' && existingRule && 'headers' in existingRule
+          ? (existingRule.headers as Record<string, string> | undefined)
+          : undefined
+
+      nuxt.options.routeRules[routePath] = {
+        ...existingRule,
+        headers: {
+          ...existingHeaders,
+          'X-Robots-Tag': 'noindex, nofollow',
+        },
+      }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Add X-Robots-Tag header for draft posts to prevent indexing
const posts = loadBlogPosts(blogDir)
for (const post of posts) {
if (post.draft) {
nuxt.options.routeRules ||= {}
nuxt.options.routeRules[`/blog/${post.slug}`] = {
headers: { 'X-Robots-Tag': 'noindex, nofollow' },
}
})
}
}
// Add X-Robots-Tag header for draft posts to prevent indexing
const posts = loadBlogPosts(blogDir)
for (const post of posts) {
if (!post.draft) continue
nuxt.options.routeRules ||= {}
const routePath = post.path || `/blog/${post.slug}`
const existingRule = nuxt.options.routeRules[routePath] ?? {}
const existingHeaders
= typeof existingRule === 'object' && existingRule && 'headers' in existingRule
? (existingRule.headers as Record<string, string> | undefined)
: undefined
nuxt.options.routeRules[routePath] = {
...existingRule,
headers: {
...existingHeaders,
'X-Robots-Tag': 'noindex, nofollow',
},
}
}

@danielroe
Copy link
Member Author

danielroe commented Mar 2, 2026

the blog posts shouldn't be visible on the index (that's a bug!) or crawlable by search engines, so you have to know the exact link.

but it's possibly helpful for sharing links in advance, and getting review from people. (like the oss pledge does)

@whitep4nth3r
Copy link
Contributor

We could still share the direct link even when it was a draft, but I do like the draft banner and the noindex, good additions.

Fix up the bug and I'll look again :)

@codecov
Copy link

codecov bot commented Mar 2, 2026

Codecov Report

❌ Patch coverage is 85.71429% with 1 line in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/BlogPostListCard.vue 75.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
modules/blog.ts (1)

95-103: ⚠️ Potential issue | 🟠 Major

Use post.path and merge existing route rules instead of overwriting.

Line 100 hardcodes /blog/${post.slug} and replaces the whole rule object. This can miss draft posts using custom paths and can drop existing rule fields/headers for that route.

💡 Proposed fix
-    for (const post of posts) {
-      if (post.draft) {
-        nuxt.options.routeRules ||= {}
-        nuxt.options.routeRules[`/blog/${post.slug}`] = {
-          headers: { 'X-Robots-Tag': 'noindex, nofollow' },
-        }
-      }
-    }
+    for (const post of posts) {
+      if (!post.draft) continue
+
+      nuxt.options.routeRules ||= {}
+      const routePath = post.path || `/blog/${post.slug}`
+      const existingRule = nuxt.options.routeRules[routePath] ?? {}
+      const existingHeaders
+        = typeof existingRule === 'object' && existingRule && 'headers' in existingRule
+          ? (existingRule.headers as Record<string, string> | undefined)
+          : undefined
+
+      nuxt.options.routeRules[routePath] = {
+        ...existingRule,
+        headers: {
+          ...existingHeaders,
+          'X-Robots-Tag': 'noindex, nofollow',
+        },
+      }
+    }
#!/bin/bash
set -euo pipefail

echo "1) Draft posts with custom path frontmatter (these should map to routeRules via post.path):"
fd -e md . app/pages/blog | while read -r f; do
  if rg -n '^draft:\s*true\b' "$f" >/dev/null && rg -n '^path:\s*' "$f" >/dev/null; then
    echo "--- $f"
    rg -n '^(draft|path|slug):' "$f"
  fi
done

echo
echo "2) Current routeRules assignment pattern in modules/blog.ts:"
rg -n -C4 'routeRules|X-Robots-Tag|/blog/\$\{post\.slug\}' modules/blog.ts

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c8d7926 and 3910c81.

📒 Files selected for processing (1)
  • modules/blog.ts

@danielroe
Copy link
Member Author

ah - before merging, I actually filtered them out so the drafts weren't even accessible by direct link (I didn't want it looking like a final article)... but as I reflected I realised it was a useful thing to be able to share.

hopefully bug now fixed 🙏

@danielroe danielroe requested review from whitep4nth3r and removed request for whitep4nth3r March 2, 2026 14:07
<span class="text-xs text-fg-muted font-mono">{{ published }}</span>
<div class="flex items-center gap-2">
<span class="text-xs text-fg-muted font-mono">{{ published }}</span>
<span
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not seeing this badge on the preview

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh it's the list card, if we're not showing drafts on the blog index we don't need this

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be useful when previewing on preview URLs and locally.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But locally we don't show drafts on the index?

I'm confused, but you know more about this than I.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does now show drafts on the /blog index locally and on preview deploys (but with badge) and in production, only allows accessing them directly (and displays a banner at the top of the page).

likely you checked before I pushed (yet another) fix 🤦

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah perfect.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
modules/blog.ts (1)

96-103: ⚠️ Potential issue | 🟠 Major

Use post.path and merge existing route rules instead of overwriting them.

At Line 101, hardcoding /blog/${post.slug} can miss draft posts with custom path, so X-Robots-Tag may not be applied. Also, direct assignment replaces any existing route rule config for that route.

Suggested fix
-    for (const post of posts) {
-      if (post.draft) {
-        nuxt.options.routeRules ||= {}
-        nuxt.options.routeRules[`/blog/${post.slug}`] = {
-          headers: { 'X-Robots-Tag': 'noindex, nofollow' },
-        }
-      }
-    }
+    for (const post of posts) {
+      if (!post.draft) continue
+
+      nuxt.options.routeRules ||= {}
+      const routePath = post.path || `/blog/${post.slug}`
+      const existingRule = nuxt.options.routeRules[routePath] ?? {}
+      const existingHeaders
+        = typeof existingRule === 'object' && existingRule && 'headers' in existingRule
+          ? (existingRule.headers as Record<string, string> | undefined)
+          : undefined
+
+      nuxt.options.routeRules[routePath] = {
+        ...existingRule,
+        headers: {
+          ...existingHeaders,
+          'X-Robots-Tag': 'noindex, nofollow',
+        },
+      }
+    }

Run this to verify whether draft posts with explicit path exist and to inspect current route-rule handling:

#!/bin/bash
set -euo pipefail

echo "Draft/frontmatter path usage in blog markdown:"
rg -n --type=md -C2 '^\s*draft:\s*true\s*$|^\s*path:\s*' app/pages/blog

echo
echo "Route rule construction in modules/blog.ts:"
rg -n -C3 'routeRules|X-Robots-Tag|post\.slug|post\.path' modules/blog.ts
🧹 Nitpick comments (1)
modules/blog.ts (1)

85-97: Consider loading posts once during setup.

loadBlogPosts(blogDir) is called at Line 85 and again at Line 97. Reusing a single in-memory result would avoid duplicate file reads and frontmatter parsing.


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3910c81 and a22b16a.

📒 Files selected for processing (1)
  • modules/blog.ts

@danielroe danielroe added this pull request to the merge queue Mar 2, 2026
Merged via the queue into main with commit 4c4fe63 Mar 2, 2026
21 checks passed
@danielroe danielroe deleted the feat/drafts branch March 2, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants