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 } : {}) });
......
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