Skip to content

Commit c8d7926

Browse files
committed
feat: publish draft posts (with badge + noindex)
1 parent 33e9814 commit c8d7926

8 files changed

Lines changed: 45 additions & 23 deletions

File tree

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": {

lunaria/files/en-GB.json

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

lunaria/files/en-US.json

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

modules/blog.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,10 @@ import { read } from 'gray-matter'
88
import { safeParse } from 'valibot'
99
import { BlogPostSchema, type BlogPostFrontmatter } from '../shared/schemas/blog'
1010
import { globSync } from 'tinyglobby'
11-
import { isProduction } from '../config/env'
1211

1312
/**
1413
* Scans the blog directory for .md files and extracts validated frontmatter.
15-
* Returns only non-draft posts sorted by date descending.
14+
* Returns all posts (including drafts) sorted by date descending.
1615
*/
1716
function loadBlogPosts(blogDir: string): BlogPostFrontmatter[] {
1817
const files: string[] = globSync(join(blogDir, '*.md'))
@@ -35,8 +34,6 @@ function loadBlogPosts(blogDir: string): BlogPostFrontmatter[] {
3534
const result = safeParse(BlogPostSchema, frontmatter)
3635
if (!result.success) continue
3736

38-
if (result.output.draft) continue
39-
4037
posts.push(result.output)
4138
}
4239

@@ -95,25 +92,15 @@ export default defineNuxtModule({
9592

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

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-
}
95+
// Add X-Robots-Tag header for draft posts to prevent indexing
96+
const posts = loadBlogPosts(blogDir)
97+
for (const post of posts) {
98+
if (post.draft) {
99+
nuxt.options.routeRules ||= {}
100+
nuxt.options.routeRules[`/blog/${post.slug}`] = {
101+
headers: { 'X-Robots-Tag': 'noindex, nofollow' },
115102
}
116-
})
103+
}
117104
}
118105
},
119106
})

0 commit comments

Comments
 (0)