Commit 6a6670d9 by PLN (Algolia)

blog: accessors and routes for the folder model

lib/blog.js now reads content/blog/<slug>/ (meta.md + fr.md/en.md):
listing shows the requested language with fallback to the first
available one, getBlogPost(slug, lang) reports untranslated posts via
an untranslated field, and the draft/share accessors keep parsing
<slug>-<token> from meta.md. Draft filtering stays keyed on the shared
draftsVisible() rule, now exported from lib/content.

/blog/[slug] renders the default language with an FR/EN switch when a
post exists in both; a new /blog/[slug]/[lang] route serves the other
language (static params only mint languages with content). A missing
language renders an honest banner linking to the one that exists.
yarn post scaffolds the folder shape. Flat collections are untouched.
parent 48f3c0e4
import Link from "next/link";
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.
* The article markup shared by /blog/[slug], /blog/[slug]/[lang] and the
* unlisted /blog/d/[slug] draft route — one source of truth, no duplicated
* prose markup.
*
* `switcher` / `banner` come from buildLanguageNav (language-nav.js) on the
* public routes; the draft route passes neither (it ships exactly as before).
*/
export default function PostArticle({ post }) {
export default function PostArticle({ post, switcher, banner }) {
return (
<Layout>
<article className={styles.article}>
{switcher && switcher.length > 1 && (
<nav className={styles.langSwitch} aria-label="Language">
{switcher.map((item, index) => (
<span key={item.lang}>
{index > 0 && " / "}
{item.lang === post.lang ? (
<b>{item.lang.toUpperCase()}</b>
) : (
<Link href={item.href}>{item.lang.toUpperCase()}</Link>
)}
</span>
))}
</nav>
)}
<h1 className={utilStyles.headingXl}>{post.title}</h1>
<div className={`${styles.meta} ${utilStyles.lightText}`}>
<Date dateString={post.date} />
......@@ -22,6 +41,12 @@ export default function PostArticle({ post }) {
</ul>
)}
</div>
{banner && (
<p className={styles.untranslated}>
{banner.text}{" "}
<Link href={banner.href}>{banner.label}</Link>
</p>
)}
{post.description && <p className={styles.description}>{post.description}</p>}
<div className={styles.prose} dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
......
import { notFound } from "next/navigation";
import PostArticle from "../../PostArticle";
import { buildLanguageNav } from "../../language-nav";
import { BLOG_LANGS, DEFAULT_LANG, getAllBlogSlugs, getBlogLanguages, getBlogPost } from "@/lib/blog";
/**
* Non-default language route: `/blog/<slug>/<lang>`. The default language is
* canonical at `/blog/<slug>`, so `fr` 404s here and static params only mint
* languages that actually have content.
*/
export function generateStaticParams() {
const params = getAllBlogSlugs()
.flatMap((slug) =>
BLOG_LANGS.filter((lang) => lang !== DEFAULT_LANG).map((lang) => ({ slug, lang })),
)
.filter(({ slug, lang }) => getBlogLanguages(slug).includes(lang));
// 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 params.length > 0 ? params : [{ slug: "-", lang: "en" }];
}
export async function generateMetadata({ params }) {
const { slug, lang } = await params;
const post = await getBlogPost(slug, lang).catch(() => null);
if (!post) return {};
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
url: `/blog/${post.slug}`,
publishedTime: post.date,
images: ['/images/profile.png'],
},
};
}
export default async function BlogPostLang({ params }) {
const { slug, lang } = await params;
if (lang === DEFAULT_LANG || !BLOG_LANGS.includes(lang)) notFound();
const post = await getBlogPost(slug, lang).catch(() => null);
if (!post) notFound();
const { switcher, banner } = buildLanguageNav(post);
return <PostArticle post={post} switcher={switcher} banner={banner} />;
}
import { notFound } from "next/navigation";
import PostArticle from "../PostArticle";
import { buildLanguageNav } from "../language-nav";
import { getAllBlogSlugs, getBlogPost } from "@/lib/blog";
export function generateStaticParams() {
......@@ -32,5 +33,6 @@ export default async function BlogPost({ params }) {
const post = await getBlogPost(slug).catch(() => null);
if (!post) notFound();
return <PostArticle post={post} />;
const { switcher, banner } = buildLanguageNav(post);
return <PostArticle post={post} switcher={switcher} banner={banner} />;
}
......@@ -154,3 +154,45 @@
letter-spacing: 0.08em;
vertical-align: middle;
}
/* fr/en badge (index): the post exists in both languages */
.langBadge {
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
font-size: 0.7rem;
letter-spacing: 0.08em;
vertical-align: middle;
}
/* Language switch on a post page (both languages available) */
.langSwitch {
font-size: 0.8rem;
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
}
.langSwitch a {
color: var(--link);
}
.langSwitch b {
font-weight: 600;
}
/* Untranslated notice: the requested language has no content */
.untranslated {
margin: 1rem 0 0;
padding: 0.6rem 0.9rem;
border-left: 3px solid var(--border);
border-radius: 0 8px 8px 0;
background: var(--surface-raised);
color: var(--text-muted);
font-size: 0.9rem;
}
.untranslated a {
color: var(--link);
}
/**
* Language navigation data for a blog post page, shared by /blog/[slug] and
* /blog/[slug]/[lang]. Pure data — the pages (and PostArticle) render it.
*
* - `switcher`: the FR/EN links shown when a post exists in both languages.
* The default language stays canonical at /blog/<slug>.
* - `banner`: set when the requested language has no content — the honest
* notice plus a link to the language that does exist.
*/
export function buildLanguageNav(post) {
const base = `/blog/${post.slug}`;
const nav = { switcher: [], banner: null };
if (post.languages.length > 1) {
nav.switcher = [
{ lang: 'fr', href: base },
{ lang: 'en', href: `${base}/en` },
];
}
if (post.untranslated === 'fr') {
nav.banner = {
text: "Cet article n'existe pas (encore) en français.",
href: `${base}/en`,
label: 'Lire la version anglaise',
};
} else if (post.untranslated === 'en') {
nav.banner = {
text: "This post doesn't exist in English (yet).",
href: base,
label: 'Read the French version',
};
}
return nav;
}
......@@ -18,12 +18,13 @@ export default function Blog() {
<section className={`${utilStyles.headingMd} ${utilStyles.padding1px}`}>
<h1 className={utilStyles.headingXl}>Blog</h1>
<ul className={utilStyles.list}>
{posts.map(({ slug, title, date, description, draft }) => (
{posts.map(({ slug, title, date, description, draft, languages }) => (
<li className={utilStyles.listItem} key={slug}>
<Link href={`/blog/${slug}`} className={utilStyles.listItemLink}>
<span>{title}</span>
</Link>
{draft && <small className={styles.draftBadge}>DRAFT</small>}
{languages.length > 1 && <small className={styles.langBadge}>FR / EN</small>}
<br />
<small className={utilStyles.lightText}>
<Date dateString={date} />
......
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() {
return getAllContentData('blog', true);
}
/** Slug list — App Router callers map it to params. */
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { renderMarkdown } from './markdown';
import { draftsVisible, parseShareId, shareTokenOf } from './content';
/**
* Blog posts — the bilingual folder model: `content/blog/<slug>/` holds
* `meta.md` (shared metadata: canonical title, date, description, tags,
* draft, share) plus one `<lang>.md` per language (localized title, optional
* localized description, body). The slug IS the folder name.
*
* 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.
* Drafts follow the same prod/dev rule as every collection (draftsVisible).
*/
const BLOG_ROOT = path.join(process.cwd(), 'content', 'blog');
/** Language of `/blog/<slug>` — the canonical URL stays default-language. */
export const DEFAULT_LANG = 'fr';
/** All supported languages, in canonical order. */
export const BLOG_LANGS = ['fr', 'en'];
function blogSlugs() {
return fs
.readdirSync(BLOG_ROOT)
.filter((name) => fs.statSync(path.join(BLOG_ROOT, name)).isDirectory());
}
function langFile(slug, lang) {
return path.join(BLOG_ROOT, slug, `${lang}.md`);
}
function hasLang(slug, lang) {
return fs.existsSync(langFile(slug, lang));
}
/** Languages with content for a post, canonical order. */
export function getBlogLanguages(slug) {
return BLOG_LANGS.filter((lang) => hasLang(slug, lang));
}
function readFrontmatter(file) {
return matter(fs.readFileSync(file, 'utf8')).data;
}
/**
* Shared metadata of a post. Tolerance shim: a folder without meta.md (e.g. a
* language file created by hand first) falls back to the first available
* locale file's frontmatter, so one malformed folder can't take the whole
* listing down. Hand-authored posts always carry meta.md.
*/
function readMeta(slug) {
const metaFile = path.join(BLOG_ROOT, slug, 'meta.md');
if (fs.existsSync(metaFile)) return readFrontmatter(metaFile);
for (const lang of BLOG_LANGS) {
const file = langFile(slug, lang);
if (fs.existsSync(file)) return readFrontmatter(file);
}
return {};
}
/** Merge meta.md with one language file into a post-shaped entry (no body). */
function blogEntry(slug, lang) {
const meta = readMeta(slug);
const languages = getBlogLanguages(slug);
const file = langFile(slug, lang);
if (!fs.existsSync(file)) return null;
const localized = readFrontmatter(file);
return {
slug,
...meta,
title: localized.title ?? meta.title,
description: localized.description ?? meta.description,
languages,
};
}
/** First language with content, in canonical order. */
function primaryLang(slug) {
return getBlogLanguages(slug)[0] ?? null;
}
/**
* Blog listing, newest-first. Drafts follow the prod/dev rule. `lang` picks
* the displayed title/description; posts without that language fall back to
* their first available language.
*/
export function getBlogPosts(lang = DEFAULT_LANG) {
return blogSlugs()
.map((slug) => blogEntry(slug, getBlogLanguages(slug).includes(lang) ? lang : primaryLang(slug)))
.filter(Boolean)
.filter((post) => draftsVisible() || !post.draft)
.sort((a, b) => (a.date < b.date ? 1 : -1));
}
/** Slug list — App Router callers map it to params. Drafts follow the prod/dev rule. */
export function getAllBlogSlugs() {
return getAllContentIds('blog');
return blogSlugs().filter((slug) => draftsVisible() || readMeta(slug).draft !== true);
}
/** Single post with rendered HTML body; description stays plain for metadata. */
export async function getBlogPost(slug) {
return getContentData('blog', slug, { plainDescription: true });
/**
* Single post with rendered HTML body in `lang`; when that language file is
* missing, the first available language renders instead and `untranslated`
* names the requested language the caller asked for (the banner's job).
* `description` stays plain text — the UI renders it, not the markdown chain.
*/
export async function getBlogPost(slug, lang = DEFAULT_LANG) {
if (!fs.existsSync(path.join(BLOG_ROOT, slug))) {
throw new Error(`No blog post found for blog/${slug}`);
}
const languages = getBlogLanguages(slug);
const lang_ = languages.includes(lang) ? lang : primaryLang(slug);
if (!lang_) throw new Error(`No language file found for blog/${slug}`);
const { data, content } = matter(fs.readFileSync(langFile(slug, lang_), 'utf8'));
const meta = readMeta(slug);
return {
slug,
...meta,
title: data.title ?? meta.title,
description: data.description ?? meta.description,
draft: meta.draft === true,
lang: lang_,
untranslated: lang_ === lang ? null : lang,
languages,
contentHtml: await renderMarkdown(content),
};
}
/* ------------------------------------------------------------------ *
......@@ -30,12 +136,12 @@ export async function getBlogPost(slug) {
/** Draft ids, prod included — `/blog/d/` minting. */
export function getAllBlogDraftSlugs() {
return getAllDraftSlugs('blog');
return blogSlugs().filter((slug) => readMeta(slug).draft === true);
}
/** Draft posts (frontmatter only), prod included. */
/** Draft posts (frontmatter only), prod included — no markdown rendering. */
export function getAllBlogDrafts() {
return getAllDraftEntries('blog');
return getAllBlogDraftSlugs().map((slug) => ({ slug, ...readMeta(slug) }));
}
/**
......@@ -49,15 +155,14 @@ export function getAllBlogDraftParams() {
}
/**
* Draft post by share id `<slug>-<token>`; null unless the token matches the
* file's `share` frontmatter (so guessing the slug alone renders nothing).
* 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).
*/
export async function getDraftBlogPost(param) {
export async function getDraftBlogPost(param, lang = DEFAULT_LANG) {
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;
const meta = readMeta(parsed.slug);
if (meta.draft !== true || shareTokenOf(meta) !== parsed.token) return null;
return getBlogPost(parsed.slug, lang).catch(() => null);
}
......@@ -23,7 +23,7 @@ const CONTENT_ROOT = path.join(process.cwd(), 'content');
* every other context (dev server, jest, `SHOW_DRAFTS=true` for previews)
* keeps it visible.
*/
function draftsVisible() {
export function draftsVisible() {
return process.env.NODE_ENV !== 'production' || process.env.SHOW_DRAFTS === 'true';
}
......
#!/usr/bin/env node
// Scaffold a blog post. Usage: node scripts/new-post.mjs <slug> (or `yarn post <slug>`).
// Scaffold a blog post folder. Usage: node scripts/new-post.mjs <slug> (or `yarn post <slug>`).
//
// Bilingual folder model: content/blog/<slug>/meta.md (shared metadata) +
// fr.md (the body starts in the site's default language; add en.md by hand or
// via the CMS when the translation is written).
import fs from 'node:fs';
import path from 'node:path';
......@@ -10,10 +14,10 @@ if (!slug || /[^a-z0-9-]/i.test(slug)) {
}
const date = new Date().toISOString().slice(0, 10);
const file = path.join(process.cwd(), 'content/blog', `${date}-${slug}.md`);
const dir = path.join(process.cwd(), 'content/blog', slug);
if (fs.existsSync(file)) {
console.error(`Already exists: ${file}`);
if (fs.existsSync(dir)) {
console.error(`Already exists: ${dir}`);
process.exit(1);
}
......@@ -22,19 +26,28 @@ const title = slug
.map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
.join(' ');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
file,
path.join(dir, 'meta.md'),
`---
title: ${title}
date: '${date}'
slug: ${slug}
draft: true
description: ''
tags: []
---
`,
);
fs.writeFileSync(
path.join(dir, 'fr.md'),
`---
title: ${title}
---
Start typing here.
`,
);
console.log(file);
console.log(path.join(dir, 'meta.md'));
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