Commit fe6b6c16 by PLN (Algolia)

blog: unlisted draft links at /blog/d/<slug>-<token>

Drafts with a well-formed `share` token in frontmatter ship to prod at
/blog/d/<slug>-<token>: robots noindex/nofollow, no OpenGraph, token
re-checked at render so a guessed slug renders nothing, tokenless drafts
get no unlisted path. Article markup factored into a shared PostArticle;
the public listing and /blog/[slug] keep filtering drafts out.

Also: static export requires at least one prerendered variant per dynamic
route, and prod currently has zero published posts — emit a 404 sentinel
param so the export still ships (build broke at HEAD for this reason).
parent 054c0d9d
import Layout from "@/components/layout";
import Date from "@/components/date";
import utilStyles from "@/styles/utils.module.css";
import styles from "./blog.module.css";
/**
* The article markup shared by /blog/[slug] and the unlisted /blog/d/[slug]
* draft route — one source of truth, no duplicated prose markup.
*/
export default function PostArticle({ post }) {
return (
<Layout>
<article className={styles.article}>
<h1 className={utilStyles.headingXl}>{post.title}</h1>
<div className={`${styles.meta} ${utilStyles.lightText}`}>
<Date dateString={post.date} />
{post.tags && post.tags.length > 0 && (
<ul className={styles.tags}>
{post.tags.map((tag) => (
<li key={tag}>#{tag}</li>
))}
</ul>
)}
</div>
{post.description && <p className={styles.description}>{post.description}</p>}
<div className={styles.prose} dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
</Layout>
);
}
import Layout from "@/components/layout";
import Date from "@/components/date";
import utilStyles from "@/styles/utils.module.css";
import styles from "../blog.module.css";
import { notFound } from "next/navigation";
import PostArticle from "../PostArticle";
import { getAllBlogSlugs, getBlogPost } from "@/lib/blog";
export function generateStaticParams() {
return getAllBlogSlugs().map((slug) => ({ slug }));
const slugs = getAllBlogSlugs();
// Static export needs at least one prerendered variant per dynamic route;
// with no published posts in prod, emit a 404 sentinel (the page renders
// notFound() for it) so the export still ships.
return slugs.length > 0 ? slugs.map((slug) => ({ slug })) : [{ slug: "-" }];
}
export async function generateMetadata({ params }) {
......@@ -27,25 +29,8 @@ export async function generateMetadata({ params }) {
export default async function BlogPost({ params }) {
const { slug } = await params;
const post = await getBlogPost(slug);
const post = await getBlogPost(slug).catch(() => null);
if (!post) notFound();
return (
<Layout>
<article className={styles.article}>
<h1 className={utilStyles.headingXl}>{post.title}</h1>
<div className={`${styles.meta} ${utilStyles.lightText}`}>
<Date dateString={post.date} />
{post.tags && post.tags.length > 0 && (
<ul className={styles.tags}>
{post.tags.map((tag) => (
<li key={tag}>#{tag}</li>
))}
</ul>
)}
</div>
{post.description && <p className={styles.description}>{post.description}</p>}
<div className={styles.prose} dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
</Layout>
);
return <PostArticle post={post} />;
}
import { notFound } from "next/navigation";
import PostArticle from "../../PostArticle";
import { getAllBlogDraftParams, getDraftBlogPost } from "@/lib/blog";
/**
* Unlisted draft route: `/blog/d/<slug>-<token>`.
*
* Static params mint ONLY from drafts carrying a well-formed `share` token,
* prod included (that is the point of an unlisted link). Rendering re-checks
* the token against the file's frontmatter, so a guessed slug renders
* nothing; the shared getDraftPost accessor refuses non-drafts outright.
*/
export function generateStaticParams() {
const params = getAllBlogDraftParams();
// Static export needs at least one prerendered variant per dynamic route;
// with no tokened drafts, emit a 404 sentinel (the page renders notFound()
// for it) so the export still ships.
return params.length > 0 ? params : [{ slug: "-" }];
}
export async function generateMetadata({ params }) {
const { slug: shareId } = await params;
const post = await getDraftBlogPost(shareId);
if (!post) return {};
return {
title: post.title,
description: post.description,
// Unlisted: no indexing, no link following, no OpenGraph preview
// (null clears the root layout's site-wide OG defaults).
robots: { index: false, follow: false },
openGraph: null,
};
}
export default async function DraftBlogPost({ params }) {
const { slug: shareId } = await params;
const post = await getDraftBlogPost(shareId);
if (!post) notFound();
return <PostArticle post={post} />;
}
......@@ -2,6 +2,7 @@
title: Hello World
date: '2021-04-23'
draft: true
share: 8x6nki
---
> _C'est un petit pas pour l'homme, mais un grand pas pour le serverless._
......
......@@ -2,6 +2,7 @@
title: NextJs Tutorial
date: '2021-04-23'
draft: true
share: szm8ld
---
Gotta say the [Vercel Next.JS Tutorial](https://nextjs.org/learn/basics/create-nextjs-app) was a delight to follow.
......
import { getAllContentData, getAllContentIds, getContentData } from '@/lib/content';
import {
getAllContentData,
getAllContentIds,
getAllDraftSlugs,
getAllDraftEntries,
getContentData,
getDraftPost,
parseShareId,
shareTokenOf,
} from '@/lib/content';
/** Blog posts, newest-first. Drafts follow the prod/dev rule in lib/content. */
export function getBlogPosts() {
......@@ -14,3 +23,41 @@ export function getAllBlogSlugs() {
export async function getBlogPost(slug) {
return getContentData('blog', slug, { plainDescription: true });
}
/* ------------------------------------------------------------------ *
* Draft accessors — bypass the prod/dev rule (unlisted share paths).
* ------------------------------------------------------------------ */
/** Draft ids, prod included — `/blog/d/` minting. */
export function getAllBlogDraftSlugs() {
return getAllDraftSlugs('blog');
}
/** Draft posts (frontmatter only), prod included. */
export function getAllBlogDrafts() {
return getAllDraftEntries('blog');
}
/**
* Static params for `/blog/d/[slug]`: only drafts with a well-formed `share`
* token, as `<slug>-<token>`. Tokenless drafts get NO unlisted path.
*/
export function getAllBlogDraftParams() {
return getAllBlogDrafts()
.filter((post) => shareTokenOf(post))
.map((post) => ({ slug: `${post.slug}-${shareTokenOf(post)}` }));
}
/**
* Draft post by share id `<slug>-<token>`; null unless the token matches the
* file's `share` frontmatter (so guessing the slug alone renders nothing).
*/
export async function getDraftBlogPost(param) {
const parsed = parseShareId(param);
if (!parsed) return null;
const post = await getDraftPost('blog', parsed.slug, {
plainDescription: true,
}).catch(() => null);
if (!post || shareTokenOf(post) !== parsed.token) return null;
return post;
}
......@@ -90,6 +90,72 @@ function isDraft(section: string, id: string) {
return data.draft === true;
}
/* ------------------------------------------------------------------ *
* Draft accessors — bypass draftsVisible() unconditionally.
*
* Drafts ship to prod at unlisted paths `/blog/d/<slug>-<token>` (robots
* noindex'd): the `share` frontmatter token mints the URL, and the token
* must match for the page to render. The public listing/static params keep
* using the draftsVisible() rule above — these functions never feed it.
* ------------------------------------------------------------------ */
/** `share` tokens are short lowercase alnum (see scripts/new-share-token.mjs). */
const SHARE_TOKEN_RE = /^[a-z0-9]{2,16}$/;
/** The draft's share token, or null when absent/malformed (no unlisted path then). */
export function shareTokenOf(entry: { share?: unknown } | null | undefined): string | null {
const token = entry?.share;
return typeof token === 'string' && SHARE_TOKEN_RE.test(token) ? token : null;
}
/** Parse `<slug>-<token>` FROM THE END — slugs themselves contain hyphens. */
export function parseShareId(
param: string,
): { slug: string; token: string } | null {
const i = param.lastIndexOf('-');
if (i <= 0) return null;
const token = param.slice(i + 1);
const slug = param.slice(0, i);
if (!SHARE_TOKEN_RE.test(token)) return null;
if (!/^[a-z0-9_-]+$/.test(slug)) return null;
return { slug, token };
}
/** Draft ids of a flat collection, prod included — for minting unlisted share params. */
export function getAllDraftSlugs(section: string): string[] {
return fs
.readdirSync(sectionDir(section))
.filter(isMarkdownFile)
.map(idFromFileName)
.filter((id) => isDraft(section, id));
}
/** Frontmatter-only draft entries, prod included — no markdown rendering. */
export function getAllDraftEntries(section: string): ContentFrontmatter[] {
return getAllDraftSlugs(section).map((id) => {
const { data } = matter(
fs.readFileSync(path.join(sectionDir(section), `${id}.md`), 'utf8'),
);
return normalizeEntry(id, data);
});
}
/**
* Single draft item with rendered HTML body, regardless of environment —
* but only drafts pass: a published post fetched through here throws.
*/
export async function getDraftPost(
section: string,
id: string,
options: GetContentDataOptions = {},
) {
const post = await getContentData(section, id, options);
if (!post.draft) {
throw new Error(`${section}/${id} is not a draft`);
}
return post;
}
export interface GetContentDataOptions {
/** Keep `description` as plain text (metadata) instead of rendering it to HTML. */
plainDescription?: boolean;
......
User-agent: *
Disallow: /poems/
Disallow: /blog/d/
import {
getAllBlogSlugs,
getBlogPosts,
getAllBlogDraftSlugs,
getAllBlogDrafts,
getAllBlogDraftParams,
getDraftBlogPost,
getBlogPost,
} from "@/lib/blog";
import { parseShareId, shareTokenOf, getDraftPost } from "@/lib/content";
// The unified/shiki markdown chain is ESM-only and does not load under jest
// (see jest.config.js note); these tests exercise the draft accessors, not
// rendering, so stub the renderer.
jest.mock("@/lib/markdown", () => ({
renderMarkdown: jest.fn(async (source) => source),
}));
/**
* Unlisted draft accessors. Jest runs with NODE_ENV=test (drafts visible);
* the prod side is exercised by forcing NODE_ENV=production around sync
* accessors — the same switch draftsVisible() consults at build time.
*/
async function withProdEnv(fn) {
const prevNodeEnv = process.env.NODE_ENV;
const prevShowDrafts = process.env.SHOW_DRAFTS;
process.env.NODE_ENV = "production";
delete process.env.SHOW_DRAFTS;
try {
return await fn();
} finally {
process.env.NODE_ENV = prevNodeEnv;
if (prevShowDrafts !== undefined) process.env.SHOW_DRAFTS = prevShowDrafts;
}
}
const ALL_BLOG_SLUGS = ["hello-world-again", "hello-world", "next"];
describe("blog public accessors (prod rule untouched)", () => {
it("list drafts in dev (NODE_ENV=test)", () => {
expect(getAllBlogSlugs()).toEqual(expect.arrayContaining(ALL_BLOG_SLUGS));
expect(getBlogPosts().map((p) => p.slug)).toEqual(
expect.arrayContaining(ALL_BLOG_SLUGS),
);
});
it("filter drafts out in prod — /blog/[slug] never renders them", async () => {
await withProdEnv(() => {
expect(getAllBlogSlugs()).toEqual([]);
expect(getBlogPosts()).toEqual([]);
});
});
it("single-item getter never bypasses the rule in prod", async () => {
// It reads any file by design (posts exist only via getAllBlogSlugs);
// the route-level guarantee is that prod params are empty, verified above.
await withProdEnv(async () => {
const post = await getBlogPost("hello-world");
expect(post.draft).toBe(true);
expect(post.title).toBe("Hello World");
});
});
});
describe("draft accessors bypass draftsVisible()", () => {
it("getAllBlogDraftSlugs returns every draft regardless of NODE_ENV", async () => {
expect(getAllBlogDraftSlugs()).toEqual(expect.arrayContaining(ALL_BLOG_SLUGS));
await withProdEnv(() => {
expect(getAllBlogDraftSlugs()).toEqual(expect.arrayContaining(ALL_BLOG_SLUGS));
});
});
it("getAllBlogDrafts keeps prod-blind frontmatter listing", async () => {
await withProdEnv(() => {
const entries = getAllBlogDrafts();
expect(entries.map((e) => e.slug)).toEqual(expect.arrayContaining(ALL_BLOG_SLUGS));
// No markdown rendering — entries carry frontmatter only.
expect(entries[0]).not.toHaveProperty("contentHtml");
});
});
it("getAllBlogDraftParams mints <slug>-<token> only for tokened drafts", () => {
const params = getAllBlogDraftParams();
const ids = params.map((p) => p.slug);
expect(ids).toContain("hello-world-8x6nki");
expect(ids).toContain("next-szm8ld");
// hello-world-again has no `share` token — no unlisted path.
expect(ids).not.toContain("hello-world-again");
expect(ids.every((id) => parseShareId(id) !== null)).toBe(true);
});
it("getDraftPost refuses non-drafts", async () => {
await expect(getDraftPost("poems", "berg")).rejects.toThrow(/not a draft/);
});
it("getDraftPost renders a draft regardless of NODE_ENV", async () => {
await withProdEnv(async () => {
const post = await getDraftPost("blog", "hello-world", { plainDescription: true });
expect(post.draft).toBe(true);
expect(typeof post.contentHtml).toBe("string");
});
});
});
describe("getDraftBlogPost — share id gate", () => {
it("renders when the token matches the file frontmatter", async () => {
const post = await getDraftBlogPost("hello-world-8x6nki");
expect(post).not.toBeNull();
expect(post.slug).toBe("hello-world");
expect(post.title).toBe("Hello World");
});
it("rejects a guessed token on a real draft slug", async () => {
expect(await getDraftBlogPost("hello-world-guess")).toBeNull();
});
it("rejects malformed share ids", async () => {
expect(await getDraftBlogPost("hello-world")).toBeNull(); // no token
expect(await getDraftBlogPost("hello-world-8X6NKI")).toBeNull(); // uppercase
expect(await getDraftBlogPost("hello-world-8x6nki-extra")).toBeNull();
});
it("rejects non-draft slugs", async () => {
await expect(getDraftBlogPost("berg-nopass")).resolves.toBeNull();
});
});
describe("share id parsing", () => {
it("splits from the END — slugs contain hyphens", () => {
expect(parseShareId("hello-world-8x6nki")).toEqual({
slug: "hello-world",
token: "8x6nki",
});
expect(parseShareId("2021-04-23-hello-world-ab12cd")).toEqual({
slug: "2021-04-23-hello-world",
token: "ab12cd",
});
expect(parseShareId("next-szm8ld")).toEqual({ slug: "next", token: "szm8ld" });
});
it("returns null for absent or malformed tokens", () => {
// Shape-ambiguous split ("hello-world" → slug "hello", token "world"):
// the parser can't know — the token match in getDraftBlogPost rejects it
// (covered in the share id gate suite).
expect(parseShareId("hello-world")).toEqual({ slug: "hello", token: "world" });
expect(parseShareId("hello-world-")).toBeNull();
expect(parseShareId("-8x6nki")).toBeNull();
expect(parseShareId("hello-world-8X6NKI")).toBeNull();
expect(parseShareId("hello-world-8x6nk!")).toBeNull();
expect(parseShareId("hello-world-0123456789abcdef0")).toBeNull(); // too long
// Underscores are legal in slugs (poems use them); parse succeeds —
// the gate is the token match, not the shape.
expect(parseShareId("hello_world-8x6nki")).toEqual({
slug: "hello_world",
token: "8x6nki",
});
});
it("shareTokenOf gates on the token shape", () => {
expect(shareTokenOf({ share: "8x6nki" })).toBe("8x6nki");
expect(shareTokenOf({})).toBeNull();
expect(shareTokenOf({ share: "" })).toBeNull();
expect(shareTokenOf({ share: "GOLDEN-SUN" })).toBeNull();
expect(shareTokenOf(null)).toBeNull();
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment