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
- `getAllContentIds(section)` — plain `string[]`; dynamic pages map it to params via `.map((id) => ({ id }))`.
- `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).
......
......@@ -3,24 +3,25 @@ import PostArticle from "../../PostArticle";
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,
* 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.
* Static params mint from every draft, prod included (that is the point of
* an unlisted link). Rendering re-checks draft status against the folder, so
* a published slug renders nothing; getDraftBlogPost refuses non-drafts
* outright. The id is readable rather than unenumerable — a deliberate
* tradeoff — and the route ships noindex regardless.
*/
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()
// with no drafts at all, 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);
const { slug } = await params;
const post = await getDraftBlogPost(slug);
if (!post) return {};
return {
......@@ -34,8 +35,8 @@ export async function generateMetadata({ params }) {
}
export default async function DraftBlogPost({ params }) {
const { slug: shareId } = await params;
const post = await getDraftBlogPost(shareId);
const { slug } = await params;
const post = await getDraftBlogPost(slug);
if (!post) notFound();
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';
import path from 'path';
import matter from 'gray-matter';
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
......@@ -247,24 +247,23 @@ export function getAllBlogDrafts() {
}
/**
* Static params for `/blog/d/[slug]`: only drafts with a well-formed `share`
* token, as `<slug>-<token>`. Tokenless drafts get NO unlisted path.
* Static params for `/blog/d/[slug]`: every draft, at its plain folder slug.
* 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() {
return getAllBlogDrafts()
.filter((post) => shareTokenOf(post))
.map((post) => ({ slug: `${post.slug}-${shareTokenOf(post)}` }));
return getAllBlogDrafts().map((post) => ({ slug: post.slug }));
}
/**
* Draft post by share id `<slug>-<token>`; null unless the folder is a draft
* whose `share` frontmatter matches the token (so guessing the slug alone
* renders nothing).
* Draft post by unlisted id `<slug>`; null unless the folder is a draft.
* The id is guessable by design — a readable share link beats an
* unenumerable token for a personal blog, and the route ships noindex
* either way.
*/
export async function getDraftBlogPost(param, lang = DEFAULT_LANG) {
const parsed = parseShareId(param);
if (!parsed) return null;
const meta = readMeta(parsed.slug);
if (meta.draft !== true || shareTokenOf(meta) !== parsed.token) return null;
return getBlogPost(parsed.slug, lang).catch(() => null);
if (!/^[a-z0-9_-]+$/.test(param)) return null;
const meta = readMeta(param);
if (!meta || meta.draft !== true) return null;
return getBlogPost(param, lang).catch(() => null);
}
......@@ -99,28 +99,6 @@ function isDraft(section: string, id: string) {
* 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
......
......@@ -51,7 +51,6 @@ collections:
- { label: Description, name: description, widget: string, required: false }
- { label: Tags, name: tags, widget: list, required: false, default: [] }
- { 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
label: Blog FR
......
......@@ -55,7 +55,6 @@ const FIXTURES = [
slug: "_fixture-addr-secret",
date: "2035-01-01",
draft: true,
share: "abc123",
fr: { title: "Secret", slug: "secret-billet" },
},
];
......@@ -71,7 +70,7 @@ beforeAll(() => {
fs.mkdirSync(path.join(DIR, fx.slug), { recursive: true });
fs.writeFileSync(
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"]) {
if (fx[lang]) writeLang(fx.slug, lang, { title: fx[lang].title, ...(fx[lang].slug ? { slug: fx[lang].slug } : {}) });
......
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import {
BLOG_LANGS,
DEFAULT_LANG,
......@@ -13,7 +14,7 @@ import {
getDraftBlogPost,
} from "@/lib/blog";
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
// (see jest.config.js note); these tests exercise the folder accessors, not
......@@ -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
......@@ -52,6 +64,13 @@ const ALL_BLOG_SLUGS = ["hello-world-again", "hello-world", "next"];
const FIXTURE_SLUG = "_fixture-bilingual";
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(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
......@@ -67,10 +86,21 @@ beforeAll(() => {
path.join(FIXTURE_DIR, "en.md"),
"---\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(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.rmSync(DRAFT_DIR, { recursive: true, force: true });
});
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
// than exact equality — the pinned property is drafts excluded.
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(getBlogPosts().map((p) => p.slug)).not.toContain(draft);
}
......@@ -99,9 +132,8 @@ describe("blog public accessors (prod rule untouched)", () => {
// It reads any folder 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");
const post = await getBlogPost(DRAFT_SLUG);
expect(post.draft).toBe(true);
expect(post.title).toBe("Hello World");
});
});
......@@ -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);
expect(dates).toEqual([...dates].sort().reverse());
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"]);
});
......@@ -122,7 +160,7 @@ describe("blog public accessors (prod rule untouched)", () => {
describe("bilingual accessors", () => {
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(FIXTURE_SLUG)).toEqual(["fr", "en"]);
});
......@@ -146,15 +184,14 @@ describe("bilingual accessors", () => {
it("falls back to the available language and flags the banner", async () => {
// 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.untranslated).toBeNull();
// 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.untranslated).toBe("en");
expect(en.title).toBe("Hello World");
// fr requested on an en-only post: en renders, banner data present.
const next = await getBlogPost("next");
......@@ -184,42 +221,44 @@ describe("buildLanguageNav — banner and switcher data", () => {
slug: "next", lang: "en", untranslated: "fr", languages: ["en"],
});
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");
const enMissing = buildLanguageNav({
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");
});
});
describe("draft accessors bypass draftsVisible()", () => {
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(() => {
expect(getAllBlogDraftSlugs()).toEqual(expect.arrayContaining(ALL_BLOG_SLUGS));
expect(getAllBlogDraftSlugs()).toContain(DRAFT_SLUG);
});
});
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));
expect(entries.map((e) => e.slug)).toContain(DRAFT_SLUG);
// No markdown rendering — entries carry frontmatter only.
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 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);
// The unlisted id IS the slug (smbc-style): short, readable, unique by
// construction — one folder per post.
expect(ids).toContain(DRAFT_SLUG);
});
it("getDraftPost (flat collections) refuses non-drafts", async () => {
......@@ -228,73 +267,28 @@ describe("draft accessors bypass draftsVisible()", () => {
it("getDraftBlogPost renders a draft regardless of NODE_ENV", async () => {
await withProdEnv(async () => {
const post = await getDraftBlogPost("hello-world-8x6nki");
const post = await getDraftBlogPost(DRAFT_SLUG);
expect(post.draft).toBe(true);
expect(typeof post.contentHtml).toBe("string");
});
});
});
describe("getDraftBlogPost — share id gate", () => {
it("renders when the token matches meta.md frontmatter", async () => {
const post = await getDraftBlogPost("hello-world-8x6nki");
describe("getDraftBlogPost — draft gate", () => {
it("renders by plain slug when the folder is a draft", async () => {
const post = await getDraftBlogPost(DRAFT_SLUG);
expect(post).not.toBeNull();
expect(post.slug).toBe("hello-world");
expect(post.title).toBe("Hello World");
expect(post.slug).toBe(DRAFT_SLUG);
});
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 malformed ids", async () => {
expect(await getDraftBlogPost("")).toBeNull();
expect(await getDraftBlogPost("../escape")).toBeNull();
expect(await getDraftBlogPost("Hello World")).toBeNull();
});
it("rejects non-draft slugs", async () => {
expect(await getDraftBlogPost(`${FIXTURE_SLUG}-abc123`)).toBeNull();
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();
expect(await getDraftBlogPost(FIXTURE_SLUG)).toBeNull();
await expect(getDraftBlogPost("berg")).resolves.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