From 6388112597c88e37c5835df19b4b7de8d05089c6 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Fri, 26 Jun 2026 21:52:44 +0530 Subject: [PATCH] Hide draft blog posts from listings, feeds, and search A blog post with `draft: true` in frontmatter stays reachable at its direct URL but is excluded from the blog listing (plus featured hero and pagination), author pages, category pages and chips, the "read next" list, RSS, the JSON feed, and the llms.txt / llms-full.txt aggregators. It also emits a noindex robots meta so search engines drop it while the URL stays live. Implemented via a single publishedPosts (non-draft) source of truth in blog/content.ts, reused across the blog layout context, feeds, and llms generators. unlisted behavior is unchanged. --- src/markdoc/layouts/Post.svelte | 6 ++++++ src/routes/blog/+layout.ts | 4 ++-- src/routes/blog/content.ts | 11 +++++++++-- src/routes/blog/feed.json/+server.ts | 6 +++--- src/routes/blog/rss.xml/+server.ts | 4 ++-- src/routes/llms-full.txt/+server.ts | 12 ++++++++++++ src/routes/llms.txt/+server.ts | 12 ++++++++++++ 7 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/markdoc/layouts/Post.svelte b/src/markdoc/layouts/Post.svelte index dcb56d928db..6b203410dce 100644 --- a/src/markdoc/layouts/Post.svelte +++ b/src/markdoc/layouts/Post.svelte @@ -43,6 +43,8 @@ export let callToAction: BlogCallToActionInput; export let lastUpdated: string; export let faqs: { question: string; answer: string }[] | undefined = undefined; + /** Frontmatter `draft: true`: the post stays reachable by its direct URL but is hidden from all listings/feeds and marked noindex. */ + export let draft = false; const posts = getContext('posts')?.filter( (post) => !(post.unlisted ?? false) && !(post.draft ?? false) @@ -133,6 +135,10 @@ + {#if draft} + + + {/if} {resolvedMetaTitle + TITLE_SUFFIX} diff --git a/src/routes/blog/+layout.ts b/src/routes/blog/+layout.ts index 80d57a6b898..ef71bc51742 100644 --- a/src/routes/blog/+layout.ts +++ b/src/routes/blog/+layout.ts @@ -1,8 +1,8 @@ -import { posts, authors, categories } from './content'; +import { publishedPosts, authors, categories } from './content'; export async function load({ data }) { return { - posts, + posts: publishedPosts, authors, categories, rawContent: data.rawContent diff --git a/src/routes/blog/content.ts b/src/routes/blog/content.ts index 1880a2fb944..70a73fb6c57 100644 --- a/src/routes/blog/content.ts +++ b/src/routes/blog/content.ts @@ -81,6 +81,11 @@ export const posts = Object.entries(postsGlob) return b.date.getTime() - a.date.getTime(); }); +// Posts visible to the public. Drafts are excluded everywhere except their own +// direct URL: the /blog/post/ route still renders a draft (and emits a +// noindex meta via Post.svelte), but it never appears in any listing or feed. +export const publishedPosts = posts.filter((post) => !post.draft); + export const authors = Object.values(authorsGlob).map((authorList) => { const { frontmatter } = authorList as { frontmatter: AuthorData; @@ -115,12 +120,14 @@ export const normalizeCategory = (str: string) => str?.replace(/\s+/g, '-').toLo export const getBlogEntries = () => { const filteredCategories = categories.filter((category) => - posts.some((post) => normalizeCategory(post.category) === normalizeCategory(category.name)) + publishedPosts.some( + (post) => normalizeCategory(post.category) === normalizeCategory(category.name) + ) ); return { authors, filteredCategories, - posts: posts.filter((post) => !post.unlisted) + posts: publishedPosts.filter((post) => !post.unlisted) }; }; diff --git a/src/routes/blog/feed.json/+server.ts b/src/routes/blog/feed.json/+server.ts index ea45f6ee037..e3d01942f52 100644 --- a/src/routes/blog/feed.json/+server.ts +++ b/src/routes/blog/feed.json/+server.ts @@ -1,12 +1,12 @@ import type { RequestHandler } from './$types'; -import { posts } from '../content'; +import { publishedPosts } from '../content'; import { json } from '@sveltejs/kit'; export const prerender = true; export const GET: RequestHandler = () => { return json({ - posts, - total: Object.keys(posts).length + posts: publishedPosts, + total: publishedPosts.length }); }; diff --git a/src/routes/blog/rss.xml/+server.ts b/src/routes/blog/rss.xml/+server.ts index cfc2cf1ecf8..f034aafef9c 100644 --- a/src/routes/blog/rss.xml/+server.ts +++ b/src/routes/blog/rss.xml/+server.ts @@ -1,5 +1,5 @@ import type { RequestHandler } from './$types'; -import { posts } from '../content'; +import { publishedPosts } from '../content'; export const prerender = true; @@ -26,7 +26,7 @@ export const GET: RequestHandler = () => { https://appwrite.io Appwrite is an open-source platform for building applications at any scale, using your preferred programming languages and tools. - ${posts + ${publishedPosts .map( (post) => ` ${encodeText(post.title)} diff --git a/src/routes/llms-full.txt/+server.ts b/src/routes/llms-full.txt/+server.ts index 9e7c33134a9..233460f6f9f 100644 --- a/src/routes/llms-full.txt/+server.ts +++ b/src/routes/llms-full.txt/+server.ts @@ -1,5 +1,6 @@ import type { RequestHandler } from '@sveltejs/kit'; import { SPECIAL_PAGES } from '../llms-config'; +import { publishedPosts } from '../blog/content'; export const prerender = true; @@ -9,6 +10,11 @@ const markdocAndMarkdownFiles = import.meta.glob('$routes/**/*.{markdoc,md}', { eager: true }); +// Published (non-draft) blog post slugs — the single source of truth for what is public. +const PUBLISHED_BLOG_SLUGS = new Set( + publishedPosts.map((p) => p.href.split('/blog/post/')[1]).filter(Boolean) +); + function stripRouteGroups(routePath: string): string { return routePath.replace(/\([^/]+\)\//g, ''); } @@ -128,6 +134,12 @@ ${page.fullContent} continue; } + // Skip draft blog posts: only include published ones (single source of truth) + if (href.startsWith('/blog/post')) { + const slug = href.split('/blog/post/')[1]?.replace(/\/+$/, ''); + if (!slug || !PUBLISHED_BLOG_SLUGS.has(slug)) continue; + } + const url = new URL(href, base).toString(); const fmOrH1 = extractTitle(raw); diff --git a/src/routes/llms.txt/+server.ts b/src/routes/llms.txt/+server.ts index fab781cbfe5..60b5b58f261 100644 --- a/src/routes/llms.txt/+server.ts +++ b/src/routes/llms.txt/+server.ts @@ -1,5 +1,6 @@ import type { RequestHandler } from '@sveltejs/kit'; import { SPECIAL_PAGES } from '../llms-config'; +import { publishedPosts } from '../blog/content'; export const prerender = true; @@ -10,6 +11,11 @@ const markdocAndMarkdownFiles = import.meta.glob('$routes/**/*.{markdoc,md}', { eager: true }); +// Published (non-draft) blog post slugs — the single source of truth for what is public. +const PUBLISHED_BLOG_SLUGS = new Set( + publishedPosts.map((p) => p.href.split('/blog/post/')[1]).filter(Boolean) +); + // Strip group directories like (marketing) from a route path function stripRouteGroups(routePath: string): string { return routePath.replace(/\([^/]+\)\//g, ''); @@ -148,6 +154,12 @@ export const GET: RequestHandler = ({ request }) => { continue; } + // Skip draft blog posts: only include published ones (single source of truth) + if (href.startsWith('/blog/post')) { + const slug = href.split('/blog/post/')[1]?.replace(/\/+$/, ''); + if (!slug || !PUBLISHED_BLOG_SLUGS.has(slug)) continue; + } + const url = new URL(href, base).toString(); const title = extractTitle(raw) ?? href.split('/').pop()!.replace(/[-_]/g, ' ');