Commit c49d53d1 by PLN (Algolia)

feat: per-language slugs and root-key resolution for blog posts

Each language file may declare its own localized slug (the folder name
stays the canonical slug). A post's resolvable set: canonical folder slug
to the default language, localized slugs to their language. lib/keys.js
gathers the reserved root names (routes, legacy pages, public/ files,
hardcoded floor) and fails loudly on a colliding or twice-claimed key.
parent b2bc1946
...@@ -12,6 +12,8 @@ import { draftsVisible, parseShareId, shareTokenOf } from './content'; ...@@ -12,6 +12,8 @@ import { draftsVisible, parseShareId, shareTokenOf } from './content';
* *
* A post missing one language file is a valid post in the other; accessors * A post missing one language file is a valid post in the other; accessors
* report that via `untranslated` so the UI can show the honest banner. * report that via `untranslated` so the UI can show the honest banner.
* Each language file may carry its own localized `slug:` (the folder name
* stays the canonical slug — see the Addressing section below).
* Drafts follow the same prod/dev rule as every collection (draftsVisible). * Drafts follow the same prod/dev rule as every collection (draftsVisible).
*/ */
...@@ -133,6 +135,76 @@ export async function getBlogPost(slug, lang = DEFAULT_LANG) { ...@@ -133,6 +135,76 @@ export async function getBlogPost(slug, lang = DEFAULT_LANG) {
} }
/* ------------------------------------------------------------------ * /* ------------------------------------------------------------------ *
* Addressing — per-language slugs + the root KEY namespace (DESIGN.md).
*
* A post's resolvable slug set: the canonical folder slug (→ default
* language) plus any localized `slug:` declared in fr.md/en.md (→ that
* language). Every declared slug resolves at the domain root
* (me.nech.pl/<KEY>); the folder slug is the canonical one — the root form
* is the real address, /blog/<slug> is the alias that points at it.
* ------------------------------------------------------------------ */
/** Localized `slug:` declared in one language file's frontmatter, or null. */
function localizedSlug(slug, lang) {
if (!hasLang(slug, lang)) return null;
const value = readFrontmatter(langFile(slug, lang)).slug;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
/**
* Key table of a post: every slug that resolves to it, with the language
* each renders. Canonical folder slug first (→ default language; the
* accessor's usual fallback rules apply when that file is missing), then
* localized slugs in canonical language order.
*/
export function getBlogKeys(slug) {
const keys = [{ key: slug, lang: DEFAULT_LANG }];
for (const lang of BLOG_LANGS) {
const localized = localizedSlug(slug, lang);
if (localized && localized !== slug) keys.push({ key: localized, lang });
}
return keys;
}
/**
* Resolve a root key (or /blog alias slug) to its post folder and rendering
* language: `{ slug, lang }` or null. Environment-blind — minting APIs
* apply the draft rule; this only reads the addressing table. Canonical
* folder slugs win over localized ones.
*/
export function resolveBlogKey(key) {
for (const slug of blogSlugs()) {
const hit = getBlogKeys(slug).find((entry) => entry.key === key);
if (hit) return { slug, lang: hit.lang };
}
return null;
}
/**
* Every root key of every visible post — generateStaticParams for the root
* route. Drafts follow the prod/dev rule: prod mints NOTHING for them here,
* their only prod surface stays the unlisted /blog/d/<slug>-<token> path.
*/
export function getAllBlogKeys() {
return blogSlugs()
.filter((slug) => draftsVisible() || readMeta(slug).draft !== true)
.flatMap((slug) =>
getBlogKeys(slug).map(({ key, lang }) => ({ key, slug, lang })),
);
}
/**
* The canonical address of a post — the root form, ratified in DESIGN.md:
* the canonical folder slug at the domain root. Every rendering of the post
* (root keys, /blog alias, language variant) points its canonical link
* here; the folder slug stays the real address even when a localized fr
* slug exists (that one is an extra resolvable key, not the canonical).
*/
export function blogCanonicalPath(slug) {
return `/${slug}`;
}
/* ------------------------------------------------------------------ *
* Neighbours — prev/next by date * Neighbours — prev/next by date
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
......
import fs from 'fs';
import path from 'path';
/**
* Root KEY namespace — the build-time collision guard.
*
* `me.nech.pl/<KEY>` is premium real estate: a post slug that lands at the
* domain root must never silently shadow a real route or a served file.
* The reserved set is gathered from the filesystem at build time — every
* top-level segment that already answers a URL — plus a hardcoded floor of
* infrastructure names. A colliding key fails the build loudly, naming both
* sides, instead of quietly winning (or losing) the route.
*/
const HARD_RESERVED = ['blog', 'admin', 'api', '_next', 'images', 'fonts', 'index'];
function topLevelNames(dir) {
try {
return fs.readdirSync(dir, { withFileTypes: true }).map((entry) => entry.name);
} catch {
return [];
}
}
/**
* Every name the root namespace must not take: route segments from app/ (and
* the legacy pages/ tree, when one exists) and everything served verbatim
* from public/. For public files the extensionless stem is reserved too —
* a key `favicon` would mint `out/favicon.html` beside the served
* `favicon.ico`, which is confusion worth failing on.
*/
export function reservedRootKeys() {
const root = process.cwd();
const reserved = new Set(HARD_RESERVED);
for (const name of topLevelNames(path.join(root, 'app'))) reserved.add(name);
for (const name of topLevelNames(path.join(root, 'pages'))) reserved.add(name);
for (const name of topLevelNames(path.join(root, 'public'))) {
reserved.add(name);
const stem = name.replace(/\.[^.]+$/, '');
if (stem) reserved.add(stem);
}
return [...reserved].sort();
}
/**
* Throw loudly when a root key collides with a real route or is claimed
* twice. `claims` is a list of `[key, owner]` pairs — owner being a
* description ("post hello-world", "route /atlas") so the error names the
* collision, not just the fact. Pairs, not an object: two posts claiming
* the same key must both be nameable.
*/
export function assertRootKeysAvailable(claims) {
const reserved = reservedRootKeys();
const owners = new Map();
for (const [key, owner] of claims) {
const prior = owners.get(key);
if (prior) {
throw new Error(
`Root key collision: "${key}" is claimed by ${prior} and ${owner}. ` +
'Root keys resolve to one page — rename one of the slugs.',
);
}
owners.set(key, owner);
if (reserved.includes(key)) {
throw new Error(
`Root key collision: "${key}" (${owner}) shadows a real route or a ` +
`public file. Reserved root names: ${reserved.join(', ')}. ` +
'Rename the slug — the root namespace is premium real estate.',
);
}
}
}
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