Commit 503df710 by PLN (Algolia)

drafts: /blog/d/<slug> — the folder slug IS the share id

Drop the share-token machinery (parseShareId/shareTokenOf, the CMS field,
the generator script, meta frontmatter). Draft folder slugs are unique by
construction; a readable short id beats an unenumerable token for a
personal blog, and the route ships noindex either way.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent c474d39c
...@@ -53,9 +53,9 @@ Markdown sections are loaded server-side at build via `gray-matter` (frontmatter ...@@ -53,9 +53,9 @@ Markdown sections are loaded server-side at build via `gray-matter` (frontmatter
- `getAllContentIds(section)` — plain `string[]`; dynamic pages map it to params via `.map((id) => ({ id }))`. - `getAllContentIds(section)` — plain `string[]`; dynamic pages map it to params via `.map((id) => ({ id }))`.
- `getContentData(section, id)` — single item with rendered `contentHtml`. - `getContentData(section, id)` — single item with rendered `contentHtml`.
*Blog — bilingual folder model:* each post is a folder `content/blog/<slug>/` holding `meta.md` (shared metadata: canonical `title`, `date`, `description`, `tags`, `draft`, optional `share` token) plus one `<lang>.md` per language (`fr.md` / `en.md`) whose frontmatter carries the localized `title` (and optionally `description` and a localized `slug:`); the body is that language's content. The folder name is the canonical slug. A post missing one language file is a valid post in the other; the accessor flags it via `untranslated` and the UI shows an honest banner linking to the available language. Accessors live in **`lib/blog.js`** (the flat-collection API above does NOT serve blog): `getBlogPosts(lang)` (listing, default `fr`), `getBlogPost(slug, lang)`, `getAllBlogSlugs()`, `getBlogLanguages(slug)`; draft accessors (`getAllBlogDraftSlugs`/`getAllBlogDrafts`/`getAllBlogDraftParams`/`getDraftBlogPost`) keep minting `<slug>-<token>` share ids from meta.md's `share`. Draft filtering still runs through the shared `draftsVisible()` rule. Public routes: `/blog/[slug]` (default language, FR/EN switch when both exist), `/blog/[slug]/[lang]` (other language), `/blog/d/[slug]` (unlisted drafts, unchanged). *Blog — bilingual folder model:* each post is a folder `content/blog/<slug>/` holding `meta.md` (shared metadata: canonical `title`, `date`, `description`, `tags`, `draft`) plus one `<lang>.md` per language (`fr.md` / `en.md`) whose frontmatter carries the localized `title` (and optionally `description` and a localized `slug:`); the body is that language's content. The folder name is the canonical slug. A post missing one language file is a valid post in the other; the accessor flags it via `untranslated` and the UI shows an honest banner linking to the available language. Accessors live in **`lib/blog.js`** (the flat-collection API above does NOT serve blog): `getBlogPosts(lang)` (listing, default `fr`), `getBlogPost(slug, lang)`, `getAllBlogSlugs()`, `getBlogLanguages(slug)`; draft accessors (`getAllBlogDraftSlugs`/`getAllBlogDrafts`/`getAllBlogDraftParams`/`getDraftBlogPost`) mint the plain unlisted id `/blog/d/<slug>` — the folder slug IS the share id (smbc-style, no token). Draft filtering still runs through the shared `draftsVisible()` rule. Public routes: `/blog/[slug]` (default language, FR/EN switch when both exist), `/blog/[slug]/[lang]` (other language), `/blog/d/[slug]` (unlisted drafts, unchanged).
*Addressing — per-language slugs + the root KEY namespace* (contract: DESIGN.md "Addressing"): a post's resolvable slug set is the canonical folder slug (→ default language) plus any localized `slug:` in `fr.md`/`en.md` (→ that language). Every declared slug mints a page at the domain root — `app/[key]/page.js` — rendering the post via the same `PostArticle`; **the root form is canonical** (`blogCanonicalPath` in lib/blog.js): `/blog/<slug>` and language variants serve the same content with `alternates.canonical` pointing at `me.nech.pl/<slug>`. Drafts mint nothing at the root or `/blog` in prod — their only prod surface stays `/blog/d/<slug>-<token>`. Root keys are premium real estate: `lib/keys.js` gathers the reserved set at build time (top-level dirs under `app/` + legacy `pages/` + everything in `public/` + a hardcoded floor: blog, admin, api, _next, images, fonts, index) and `assertRootKeysAvailable` fails the build loudly on a colliding or twice-claimed key. Resolution accessors: `getBlogKeys(slug)` (key table), `resolveBlogKey(key)` → `{slug, lang}` or null, `getAllBlogKeys()` (root route's generateStaticParams). *Addressing — per-language slugs + the root KEY namespace* (contract: DESIGN.md "Addressing"): a post's resolvable slug set is the canonical folder slug (→ default language) plus any localized `slug:` in `fr.md`/`en.md` (→ that language). Every declared slug mints a page at the domain root — `app/[key]/page.js` — rendering the post via the same `PostArticle`; **the root form is canonical** (`blogCanonicalPath` in lib/blog.js): `/blog/<slug>` and language variants serve the same content with `alternates.canonical` pointing at `me.nech.pl/<slug>`. Drafts mint nothing at the root or `/blog` in prod — their only prod surface stays `/blog/d/<slug>`. Root keys are premium real estate: `lib/keys.js` gathers the reserved set at build time (top-level dirs under `app/` + legacy `pages/` + everything in `public/` + a hardcoded floor: blog, admin, api, _next, images, fonts, index) and `assertRootKeysAvailable` fails the build loudly on a colliding or twice-claimed key. Resolution accessors: `getBlogKeys(slug)` (key table), `resolveBlogKey(key)` → `{slug, lang}` or null, `getAllBlogKeys()` (root route's generateStaticParams).
Thin per-section wrappers (`lib/hydras.js`, `lib/poems.js`, `lib/talks.js`) just re-export from `@/lib/content`, binding a section name. Dynamic content pages (`app/blog/[slug]/page.js`, `app/poesie/[id]/page.js`, `app/hydra/[id]/page.js`) are async server components that pair `generateStaticParams()` + a `params`-driven fetch (`params` is a Promise — `await` it). Thin per-section wrappers (`lib/hydras.js`, `lib/poems.js`, `lib/talks.js`) just re-export from `@/lib/content`, binding a section name. Dynamic content pages (`app/blog/[slug]/page.js`, `app/poesie/[id]/page.js`, `app/hydra/[id]/page.js`) are async server components that pair `generateStaticParams()` + a `params`-driven fetch (`params` is a Promise — `await` it).
......
...@@ -3,24 +3,25 @@ import PostArticle from "../../PostArticle"; ...@@ -3,24 +3,25 @@ import PostArticle from "../../PostArticle";
import { getAllBlogDraftParams, getDraftBlogPost } from "@/lib/blog"; import { getAllBlogDraftParams, getDraftBlogPost } from "@/lib/blog";
/** /**
* Unlisted draft route: `/blog/d/<slug>-<token>`. * Unlisted draft route: `/blog/d/<slug>` — the plain folder slug, smbc-style.
* *
* Static params mint ONLY from drafts carrying a well-formed `share` token, * Static params mint from every draft, prod included (that is the point of
* prod included (that is the point of an unlisted link). Rendering re-checks * an unlisted link). Rendering re-checks draft status against the folder, so
* the token against the file's frontmatter, so a guessed slug renders * a published slug renders nothing; getDraftBlogPost refuses non-drafts
* nothing; the shared getDraftPost accessor refuses non-drafts outright. * outright. The id is readable rather than unenumerable — a deliberate
* tradeoff — and the route ships noindex regardless.
*/ */
export function generateStaticParams() { export function generateStaticParams() {
const params = getAllBlogDraftParams(); const params = getAllBlogDraftParams();
// Static export needs at least one prerendered variant per dynamic route; // Static export needs at least one prerendered variant per dynamic route;
// with no tokened drafts, emit a 404 sentinel (the page renders notFound() // with no drafts at all, emit a 404 sentinel (the page renders notFound()
// for it) so the export still ships. // for it) so the export still ships.
return params.length > 0 ? params : [{ slug: "-" }]; return params.length > 0 ? params : [{ slug: "-" }];
} }
export async function generateMetadata({ params }) { export async function generateMetadata({ params }) {
const { slug: shareId } = await params; const { slug } = await params;
const post = await getDraftBlogPost(shareId); const post = await getDraftBlogPost(slug);
if (!post) return {}; if (!post) return {};
return { return {
...@@ -34,8 +35,8 @@ export async function generateMetadata({ params }) { ...@@ -34,8 +35,8 @@ export async function generateMetadata({ params }) {
} }
export default async function DraftBlogPost({ params }) { export default async function DraftBlogPost({ params }) {
const { slug: shareId } = await params; const { slug } = await params;
const post = await getDraftBlogPost(shareId); const post = await getDraftBlogPost(slug);
if (!post) notFound(); if (!post) notFound();
return <PostArticle post={post} />; return <PostArticle post={post} />;
......
---
title: Banc d'essai — médias riches
date: '2026-11-01'
description: Brouillon agentique qui martèle le pipeline (code, figures, audio, embeds). Prose à réécrire.
tags: [meta, blog]
draft: true
share: e9mkve
---
...@@ -2,7 +2,7 @@ import fs from 'fs'; ...@@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { buildTocFromHtml, renderMarkdown } from './markdown'; import { buildTocFromHtml, renderMarkdown } from './markdown';
import { draftsVisible, parseShareId, shareTokenOf } from './content'; import { draftsVisible } from './content';
/** /**
* Blog posts — the bilingual folder model: `content/blog/<slug>/` holds * Blog posts — the bilingual folder model: `content/blog/<slug>/` holds
...@@ -247,24 +247,23 @@ export function getAllBlogDrafts() { ...@@ -247,24 +247,23 @@ export function getAllBlogDrafts() {
} }
/** /**
* Static params for `/blog/d/[slug]`: only drafts with a well-formed `share` * Static params for `/blog/d/[slug]`: every draft, at its plain folder slug.
* token, as `<slug>-<token>`. Tokenless drafts get NO unlisted path. * Draft folder slugs are unique by construction — one folder per post — so
* no token or suffixing is needed; the short id IS the slug (smbc-style).
*/ */
export function getAllBlogDraftParams() { export function getAllBlogDraftParams() {
return getAllBlogDrafts() return getAllBlogDrafts().map((post) => ({ slug: post.slug }));
.filter((post) => shareTokenOf(post))
.map((post) => ({ slug: `${post.slug}-${shareTokenOf(post)}` }));
} }
/** /**
* Draft post by share id `<slug>-<token>`; null unless the folder is a draft * Draft post by unlisted id `<slug>`; null unless the folder is a draft.
* whose `share` frontmatter matches the token (so guessing the slug alone * The id is guessable by design — a readable share link beats an
* renders nothing). * unenumerable token for a personal blog, and the route ships noindex
* either way.
*/ */
export async function getDraftBlogPost(param, lang = DEFAULT_LANG) { export async function getDraftBlogPost(param, lang = DEFAULT_LANG) {
const parsed = parseShareId(param); if (!/^[a-z0-9_-]+$/.test(param)) return null;
if (!parsed) return null; const meta = readMeta(param);
const meta = readMeta(parsed.slug); if (!meta || meta.draft !== true) return null;
if (meta.draft !== true || shareTokenOf(meta) !== parsed.token) return null; return getBlogPost(param, lang).catch(() => null);
return getBlogPost(parsed.slug, lang).catch(() => null);
} }
...@@ -99,28 +99,6 @@ function isDraft(section: string, id: string) { ...@@ -99,28 +99,6 @@ function isDraft(section: string, id: string) {
* using the draftsVisible() rule above — these functions never feed it. * 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. */ /** Draft ids of a flat collection, prod included — for minting unlisted share params. */
export function getAllDraftSlugs(section: string): string[] { export function getAllDraftSlugs(section: string): string[] {
return fs return fs
......
...@@ -51,7 +51,6 @@ collections: ...@@ -51,7 +51,6 @@ collections:
- { label: Description, name: description, widget: string, required: false } - { label: Description, name: description, widget: string, required: false }
- { label: Tags, name: tags, widget: list, required: false, default: [] } - { label: Tags, name: tags, widget: list, required: false, default: [] }
- { label: Draft, name: draft, widget: boolean, default: false } - { label: Draft, name: draft, widget: boolean, default: false }
- { label: Share token, name: share, widget: string, required: false, pattern: ['^[a-z0-9]{2,16}$', 'Lowercase letters and digits only'], hint: '6-char token for the unlisted /blog/d/<slug>-<token> link — node scripts/new-share-token.mjs' }
- name: blog-fr - name: blog-fr
label: Blog FR label: Blog FR
......
...@@ -55,7 +55,6 @@ const FIXTURES = [ ...@@ -55,7 +55,6 @@ const FIXTURES = [
slug: "_fixture-addr-secret", slug: "_fixture-addr-secret",
date: "2035-01-01", date: "2035-01-01",
draft: true, draft: true,
share: "abc123",
fr: { title: "Secret", slug: "secret-billet" }, fr: { title: "Secret", slug: "secret-billet" },
}, },
]; ];
...@@ -71,7 +70,7 @@ beforeAll(() => { ...@@ -71,7 +70,7 @@ beforeAll(() => {
fs.mkdirSync(path.join(DIR, fx.slug), { recursive: true }); fs.mkdirSync(path.join(DIR, fx.slug), { recursive: true });
fs.writeFileSync( fs.writeFileSync(
path.join(DIR, fx.slug, "meta.md"), path.join(DIR, fx.slug, "meta.md"),
`---\ntitle: ${fx.slug}\ndate: '${fx.date}'\n${fx.draft ? "draft: true\n" : ""}${fx.share ? `share: ${fx.share}\n` : ""}---\n`, `---\ntitle: ${fx.slug}\ndate: '${fx.date}'\n${fx.draft ? "draft: true\n" : ""}---\n`,
); );
for (const lang of ["fr", "en"]) { for (const lang of ["fr", "en"]) {
if (fx[lang]) writeLang(fx.slug, lang, { title: fx[lang].title, ...(fx[lang].slug ? { slug: fx[lang].slug } : {}) }); if (fx[lang]) writeLang(fx.slug, lang, { title: fx[lang].title, ...(fx[lang].slug ? { slug: fx[lang].slug } : {}) });
......
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import matter from "gray-matter";
import { import {
BLOG_LANGS, BLOG_LANGS,
DEFAULT_LANG, DEFAULT_LANG,
...@@ -13,7 +14,7 @@ import { ...@@ -13,7 +14,7 @@ import {
getDraftBlogPost, getDraftBlogPost,
} from "@/lib/blog"; } from "@/lib/blog";
import { buildLanguageNav } from "@/app/blog/language-nav"; import { buildLanguageNav } from "@/app/blog/language-nav";
import { parseShareId, shareTokenOf, getDraftPost } from "@/lib/content"; import { getDraftPost } from "@/lib/content";
// The unified/shiki markdown chain is ESM-only and does not load under jest // The unified/shiki markdown chain is ESM-only and does not load under jest
// (see jest.config.js note); these tests exercise the folder accessors, not // (see jest.config.js note); these tests exercise the folder accessors, not
...@@ -41,7 +42,18 @@ async function withProdEnv(fn) { ...@@ -41,7 +42,18 @@ async function withProdEnv(fn) {
} }
} }
const ALL_BLOG_SLUGS = ["hello-world-again", "hello-world", "next"]; // Live roster from disk — the owner edits fixtures freely; the accessors
// are what this suite pins, not any particular folder name.
const ALL_BLOG_SLUGS = fs
.readdirSync(path.join(process.cwd(), "content", "blog"))
.filter((f) => !f.startsWith("_") && fs.statSync(path.join(process.cwd(), "content", "blog", f)).isDirectory());
/** Whether a live folder is currently a draft (owner edits freely). */
function readMetaDraft(slug) {
const metaPath = path.join(process.cwd(), "content", "blog", slug, "meta.md");
if (!fs.existsSync(metaPath)) return false;
return matter(fs.readFileSync(metaPath, "utf8")).data.draft === true;
}
/** /**
* A synthetic bilingual post — the real posts are single-language by design * A synthetic bilingual post — the real posts are single-language by design
...@@ -52,6 +64,13 @@ const ALL_BLOG_SLUGS = ["hello-world-again", "hello-world", "next"]; ...@@ -52,6 +64,13 @@ const ALL_BLOG_SLUGS = ["hello-world-again", "hello-world", "next"];
const FIXTURE_SLUG = "_fixture-bilingual"; const FIXTURE_SLUG = "_fixture-bilingual";
const FIXTURE_DIR = path.join(process.cwd(), "content", "blog", FIXTURE_SLUG); const FIXTURE_DIR = path.join(process.cwd(), "content", "blog", FIXTURE_SLUG);
/**
* Synthetic draft — real posts flip between draft and published as the owner
* writes, so the draft/prod rule needs a fixture it owns outright.
*/
const DRAFT_SLUG = "_fixture-draft";
const DRAFT_DIR = path.join(process.cwd(), "content", "blog", DRAFT_SLUG);
beforeAll(() => { beforeAll(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.mkdirSync(FIXTURE_DIR, { recursive: true }); fs.mkdirSync(FIXTURE_DIR, { recursive: true });
...@@ -67,10 +86,21 @@ beforeAll(() => { ...@@ -67,10 +86,21 @@ beforeAll(() => {
path.join(FIXTURE_DIR, "en.md"), path.join(FIXTURE_DIR, "en.md"),
"---\ntitle: Bilingual Fixture (EN)\ndescription: English description.\n---\n\nEnglish body.\n", "---\ntitle: Bilingual Fixture (EN)\ndescription: English description.\n---\n\nEnglish body.\n",
); );
fs.rmSync(DRAFT_DIR, { recursive: true, force: true });
fs.mkdirSync(DRAFT_DIR, { recursive: true });
fs.writeFileSync(
path.join(DRAFT_DIR, "meta.md"),
"---\ntitle: Draft Fixture\ndate: '2026-02-02'\ndraft: true\n---\n",
);
fs.writeFileSync(
path.join(DRAFT_DIR, "fr.md"),
"---\ntitle: Brouillon\n---\n\nCorps de brouillon.\n",
);
}); });
afterAll(() => { afterAll(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.rmSync(DRAFT_DIR, { recursive: true, force: true });
}); });
describe("blog public accessors (prod rule untouched)", () => { describe("blog public accessors (prod rule untouched)", () => {
...@@ -88,7 +118,10 @@ describe("blog public accessors (prod rule untouched)", () => { ...@@ -88,7 +118,10 @@ describe("blog public accessors (prod rule untouched)", () => {
// suites' fixtures may coexist on disk, so assert membership rather // suites' fixtures may coexist on disk, so assert membership rather
// than exact equality — the pinned property is drafts excluded. // than exact equality — the pinned property is drafts excluded.
expect(getAllBlogSlugs()).toContain(FIXTURE_SLUG); expect(getAllBlogSlugs()).toContain(FIXTURE_SLUG);
for (const draft of ALL_BLOG_SLUGS) { const liveDrafts = ALL_BLOG_SLUGS.filter((slug) =>
readMetaDraft(slug),
);
for (const draft of [...liveDrafts, DRAFT_SLUG]) {
expect(getAllBlogSlugs()).not.toContain(draft); expect(getAllBlogSlugs()).not.toContain(draft);
expect(getBlogPosts().map((p) => p.slug)).not.toContain(draft); expect(getBlogPosts().map((p) => p.slug)).not.toContain(draft);
} }
...@@ -99,9 +132,8 @@ describe("blog public accessors (prod rule untouched)", () => { ...@@ -99,9 +132,8 @@ describe("blog public accessors (prod rule untouched)", () => {
// It reads any folder by design (posts exist only via getAllBlogSlugs); // It reads any folder by design (posts exist only via getAllBlogSlugs);
// the route-level guarantee is that prod params are empty, verified above. // the route-level guarantee is that prod params are empty, verified above.
await withProdEnv(async () => { await withProdEnv(async () => {
const post = await getBlogPost("hello-world"); const post = await getBlogPost(DRAFT_SLUG);
expect(post.draft).toBe(true); expect(post.draft).toBe(true);
expect(post.title).toBe("Hello World");
}); });
}); });
...@@ -110,7 +142,13 @@ describe("blog public accessors (prod rule untouched)", () => { ...@@ -110,7 +142,13 @@ describe("blog public accessors (prod rule untouched)", () => {
const dates = posts.filter((p) => ALL_BLOG_SLUGS.includes(p.slug)).map((p) => p.date); const dates = posts.filter((p) => ALL_BLOG_SLUGS.includes(p.slug)).map((p) => p.date);
expect(dates).toEqual([...dates].sort().reverse()); expect(dates).toEqual([...dates].sort().reverse());
const again = posts.find((p) => p.slug === "hello-world-again"); const again = posts.find((p) => p.slug === "hello-world-again");
expect(again.title).toBe("Hello World"); // The localized title (fr.md frontmatter) wins over meta.md's — compare
// against the fixture's own fr.md so hand edits to the fixture don't
// falsify the accessor contract.
const frTitle = matter(
fs.readFileSync(path.join("content/blog/hello-world-again/fr.md"), "utf8"),
).data.title;
expect(again.title).toBe(frTitle);
expect(again.languages).toEqual(["fr"]); expect(again.languages).toEqual(["fr"]);
}); });
...@@ -122,7 +160,7 @@ describe("blog public accessors (prod rule untouched)", () => { ...@@ -122,7 +160,7 @@ describe("blog public accessors (prod rule untouched)", () => {
describe("bilingual accessors", () => { describe("bilingual accessors", () => {
it("reports available languages from the folder contents", () => { it("reports available languages from the folder contents", () => {
expect(getBlogLanguages("hello-world")).toEqual(["fr"]); expect(getBlogLanguages("hello-world-again")).toEqual(["fr"]);
expect(getBlogLanguages("next")).toEqual(["en"]); expect(getBlogLanguages("next")).toEqual(["en"]);
expect(getBlogLanguages(FIXTURE_SLUG)).toEqual(["fr", "en"]); expect(getBlogLanguages(FIXTURE_SLUG)).toEqual(["fr", "en"]);
}); });
...@@ -146,15 +184,14 @@ describe("bilingual accessors", () => { ...@@ -146,15 +184,14 @@ describe("bilingual accessors", () => {
it("falls back to the available language and flags the banner", async () => { it("falls back to the available language and flags the banner", async () => {
// fr requested on an fr-only post: no banner. // fr requested on an fr-only post: no banner.
const fr = await getBlogPost("hello-world"); const fr = await getBlogPost("hello-world-again");
expect(fr.lang).toBe("fr"); expect(fr.lang).toBe("fr");
expect(fr.untranslated).toBeNull(); expect(fr.untranslated).toBeNull();
// en requested on an fr-only post: fr renders, banner data present. // en requested on an fr-only post: fr renders, banner data present.
const en = await getBlogPost("hello-world", "en"); const en = await getBlogPost("hello-world-again", "en");
expect(en.lang).toBe("fr"); expect(en.lang).toBe("fr");
expect(en.untranslated).toBe("en"); expect(en.untranslated).toBe("en");
expect(en.title).toBe("Hello World");
// fr requested on an en-only post: en renders, banner data present. // fr requested on an en-only post: en renders, banner data present.
const next = await getBlogPost("next"); const next = await getBlogPost("next");
...@@ -184,42 +221,44 @@ describe("buildLanguageNav — banner and switcher data", () => { ...@@ -184,42 +221,44 @@ describe("buildLanguageNav — banner and switcher data", () => {
slug: "next", lang: "en", untranslated: "fr", languages: ["en"], slug: "next", lang: "en", untranslated: "fr", languages: ["en"],
}); });
expect(frMissing.switcher).toEqual([]); expect(frMissing.switcher).toEqual([]);
expect(frMissing.banner.text).toBe("Cet article n'existe pas (encore) en français."); expect(frMissing.banner.text).toBe(
"Cet article n'existe pas (encore) en français — voici la version anglaise.",
);
expect(frMissing.banner.href).toBe("/blog/next/en"); expect(frMissing.banner.href).toBe("/blog/next/en");
const enMissing = buildLanguageNav({ const enMissing = buildLanguageNav({
slug: "foo", lang: "fr", untranslated: "en", languages: ["fr"], slug: "foo", lang: "fr", untranslated: "en", languages: ["fr"],
}); });
expect(enMissing.banner.text).toBe("This post doesn't exist in English (yet)."); expect(enMissing.banner.text).toBe(
"This post doesn't exist in English (yet) — here's the French version.",
);
expect(enMissing.banner.href).toBe("/blog/foo"); expect(enMissing.banner.href).toBe("/blog/foo");
}); });
}); });
describe("draft accessors bypass draftsVisible()", () => { describe("draft accessors bypass draftsVisible()", () => {
it("getAllBlogDraftSlugs returns every draft regardless of NODE_ENV", async () => { it("getAllBlogDraftSlugs returns every draft regardless of NODE_ENV", async () => {
expect(getAllBlogDraftSlugs()).toEqual(expect.arrayContaining(ALL_BLOG_SLUGS)); expect(getAllBlogDraftSlugs()).toContain(DRAFT_SLUG);
await withProdEnv(() => { await withProdEnv(() => {
expect(getAllBlogDraftSlugs()).toEqual(expect.arrayContaining(ALL_BLOG_SLUGS)); expect(getAllBlogDraftSlugs()).toContain(DRAFT_SLUG);
}); });
}); });
it("getAllBlogDrafts keeps prod-blind frontmatter listing", async () => { it("getAllBlogDrafts keeps prod-blind frontmatter listing", async () => {
await withProdEnv(() => { await withProdEnv(() => {
const entries = getAllBlogDrafts(); const entries = getAllBlogDrafts();
expect(entries.map((e) => e.slug)).toEqual(expect.arrayContaining(ALL_BLOG_SLUGS)); expect(entries.map((e) => e.slug)).toContain(DRAFT_SLUG);
// No markdown rendering — entries carry frontmatter only. // No markdown rendering — entries carry frontmatter only.
expect(entries[0]).not.toHaveProperty("contentHtml"); expect(entries[0]).not.toHaveProperty("contentHtml");
}); });
}); });
it("getAllBlogDraftParams mints <slug>-<token> only for tokened drafts", () => { it("getAllBlogDraftParams mints the plain folder slug for every draft", () => {
const params = getAllBlogDraftParams(); const params = getAllBlogDraftParams();
const ids = params.map((p) => p.slug); const ids = params.map((p) => p.slug);
expect(ids).toContain("hello-world-8x6nki"); // The unlisted id IS the slug (smbc-style): short, readable, unique by
expect(ids).toContain("next-szm8ld"); // construction — one folder per post.
// hello-world-again has no `share` token — no unlisted path. expect(ids).toContain(DRAFT_SLUG);
expect(ids).not.toContain("hello-world-again");
expect(ids.every((id) => parseShareId(id) !== null)).toBe(true);
}); });
it("getDraftPost (flat collections) refuses non-drafts", async () => { it("getDraftPost (flat collections) refuses non-drafts", async () => {
...@@ -228,73 +267,28 @@ describe("draft accessors bypass draftsVisible()", () => { ...@@ -228,73 +267,28 @@ describe("draft accessors bypass draftsVisible()", () => {
it("getDraftBlogPost renders a draft regardless of NODE_ENV", async () => { it("getDraftBlogPost renders a draft regardless of NODE_ENV", async () => {
await withProdEnv(async () => { await withProdEnv(async () => {
const post = await getDraftBlogPost("hello-world-8x6nki"); const post = await getDraftBlogPost(DRAFT_SLUG);
expect(post.draft).toBe(true); expect(post.draft).toBe(true);
expect(typeof post.contentHtml).toBe("string"); expect(typeof post.contentHtml).toBe("string");
}); });
}); });
}); });
describe("getDraftBlogPost — share id gate", () => { describe("getDraftBlogPost — draft gate", () => {
it("renders when the token matches meta.md frontmatter", async () => { it("renders by plain slug when the folder is a draft", async () => {
const post = await getDraftBlogPost("hello-world-8x6nki"); const post = await getDraftBlogPost(DRAFT_SLUG);
expect(post).not.toBeNull(); expect(post).not.toBeNull();
expect(post.slug).toBe("hello-world"); expect(post.slug).toBe(DRAFT_SLUG);
expect(post.title).toBe("Hello World");
}); });
it("rejects a guessed token on a real draft slug", async () => { it("rejects malformed ids", async () => {
expect(await getDraftBlogPost("hello-world-guess")).toBeNull(); expect(await getDraftBlogPost("")).toBeNull();
}); expect(await getDraftBlogPost("../escape")).toBeNull();
expect(await getDraftBlogPost("Hello World")).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 () => { it("rejects non-draft slugs", async () => {
expect(await getDraftBlogPost(`${FIXTURE_SLUG}-abc123`)).toBeNull(); expect(await getDraftBlogPost(FIXTURE_SLUG)).toBeNull();
await expect(getDraftBlogPost("berg-nopass")).resolves.toBeNull(); await expect(getDraftBlogPost("berg")).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