Skip to content

Commit 4c4fe63

Browse files
authored
feat: publish draft posts (with badge + noindex) (#1829)
1 parent b94aff5 commit 4c4fe63

File tree

6 files changed

+44
-25
lines changed

6 files changed

+44
-25
lines changed

app/components/BlogPostListCard.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ defineProps<{
1616
path: string
1717
/** For keyboard nav scaffold */
1818
index: number
19+
/** Whether this post is an unpublished draft */
20+
draft?: boolean
1921
}>()
2022
</script>
2123

@@ -30,7 +32,15 @@ defineProps<{
3032
>
3133
<!-- Text Content -->
3234
<div class="flex-1 min-w-0 text-start gap-2">
33-
<span class="text-xs text-fg-muted font-mono">{{ published }}</span>
35+
<div class="flex items-center gap-2">
36+
<span class="text-xs text-fg-muted font-mono">{{ published }}</span>
37+
<span
38+
v-if="draft"
39+
class="text-xs px-1.5 py-0.5 rounded badge-orange font-sans font-medium"
40+
>
41+
{{ $t('blog.draft_badge') }}
42+
</span>
43+
</div>
3444
<h2
3545
class="font-mono text-xl font-medium text-fg group-hover:text-primary transition-colors hover:underline"
3646
>

app/components/global/BlogPostWrapper.vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ useSeoMeta({
1111
ogTitle: props.frontmatter.title,
1212
ogDescription: props.frontmatter.description || props.frontmatter.excerpt,
1313
ogType: 'article',
14+
...(props.frontmatter.draft ? { robots: 'noindex, nofollow' } : {}),
1415
})
1516
1617
defineOgImageComponent('BlogPost', {
@@ -28,6 +29,17 @@ const blueskyPostUri = computed(() => blueskyLink.value?.postUri ?? null)
2829

2930
<template>
3031
<main class="container w-full py-8">
32+
<div
33+
v-if="frontmatter.draft"
34+
class="max-w-prose mx-auto mb-8 px-4 py-3 rounded-md border border-badge-orange/30 bg-badge-orange/5"
35+
>
36+
<div class="flex items-center gap-2 text-badge-orange">
37+
<span class="i-lucide:file-edit w-4 h-4 shrink-0" aria-hidden="true" />
38+
<span class="text-sm font-medium">
39+
{{ $t('blog.draft_banner') }}
40+
</span>
41+
</div>
42+
</div>
3143
<div v-if="frontmatter.authors" class="mb-12 max-w-prose mx-auto">
3244
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
3345
<AuthorList :authors="frontmatter.authors" variant="expanded" />

app/pages/blog/index.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ useSeoMeta({
4141
:topics="Array.isArray(post.tags) ? post.tags : placeHolder"
4242
:published="post.date"
4343
:index="idx"
44+
:draft="post.draft"
4445
/>
4546
<hr v-if="idx < posts.length - 1" class="border-border-subtle" />
4647
</template>

i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@
8585
"author": {
8686
"view_profile": "View {name}'s profile on Bluesky"
8787
},
88+
"draft_badge": "Draft",
89+
"draft_banner": "This is an unpublished draft. It may be incomplete or contain inaccuracies.",
8890
"atproto": {
8991
"view_on_bluesky": "View on Bluesky",
9092
"reply_on_bluesky": "Reply on Bluesky",

i18n/schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,12 @@
259259
},
260260
"additionalProperties": false
261261
},
262+
"draft_badge": {
263+
"type": "string"
264+
},
265+
"draft_banner": {
266+
"type": "string"
267+
},
262268
"atproto": {
263269
"type": "object",
264270
"properties": {

modules/blog.ts

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { isProduction } from '../config/env'
1212

1313
/**
1414
* Scans the blog directory for .md files and extracts validated frontmatter.
15-
* Returns only non-draft posts sorted by date descending.
15+
* Returns all posts (including drafts) sorted by date descending.
1616
*/
1717
function loadBlogPosts(blogDir: string): BlogPostFrontmatter[] {
1818
const files: string[] = globSync(join(blogDir, '*.md'))
@@ -35,8 +35,6 @@ function loadBlogPosts(blogDir: string): BlogPostFrontmatter[] {
3535
const result = safeParse(BlogPostSchema, frontmatter)
3636
if (!result.success) continue
3737

38-
if (result.output.draft) continue
39-
4038
posts.push(result.output)
4139
}
4240

@@ -78,13 +76,13 @@ export default defineNuxtModule({
7876
}),
7977
)
8078

81-
// Expose frontmatter for published posts to avoid bundling the full content
82-
// of all posts in `/blog` page.
79+
// Expose frontmatter for the `/blog` listing page.
80+
const showDrafts = nuxt.options.dev || !isProduction
8381
addTemplate({
8482
filename: 'blog/posts.ts',
8583
write: true,
8684
getContents: () => {
87-
const posts = loadBlogPosts(blogDir)
85+
const posts = loadBlogPosts(blogDir).filter(p => showDrafts || !p.draft)
8886
return [
8987
`import type { BlogPostFrontmatter } from '#shared/schemas/blog'`,
9088
``,
@@ -95,25 +93,15 @@ export default defineNuxtModule({
9593

9694
nuxt.options.alias['#blog/posts'] = join(nuxt.options.buildDir, 'blog/posts')
9795

98-
// In production, remove page routes for draft posts
99-
if (!nuxt.options.dev && isProduction) {
100-
const publishedPosts = loadBlogPosts(blogDir)
101-
const publishedSlugs = new Set(publishedPosts.map(p => p.slug))
102-
103-
nuxt.hook('pages:extend', pages => {
104-
// Walk the pages tree and remove draft blog post pages
105-
for (let i = pages.length - 1; i >= 0; i--) {
106-
const page = pages[i]!
107-
// Blog post pages are at /blog/<slug> — the file is blog/<slug>.md
108-
if (page.file?.endsWith('.md') && page.file?.includes('/blog/')) {
109-
// Extract the slug from the filename
110-
const filename = page.file.split('/').pop()?.replace('.md', '')
111-
if (filename && filename !== 'index' && !publishedSlugs.has(filename)) {
112-
pages.splice(i, 1)
113-
}
114-
}
96+
// Add X-Robots-Tag header for draft posts to prevent indexing
97+
const posts = loadBlogPosts(blogDir)
98+
for (const post of posts) {
99+
if (post.draft) {
100+
nuxt.options.routeRules ||= {}
101+
nuxt.options.routeRules[`/blog/${post.slug}`] = {
102+
headers: { 'X-Robots-Tag': 'noindex, nofollow' },
115103
}
116-
})
104+
}
117105
}
118106
},
119107
})

0 commit comments

Comments
 (0)