Commit 12387c68 by PLN (Algolia)

blog: article page anatomy — TOC rail, margin notes, code bars, lightbox, pager

parent 4fa6d331
'use client';
import { useEffect, useRef } from 'react';
/**
* Progressive-enhancement island for the article body. The page reads
* completely without it — every job here decorates DOM the static HTML
* already shipped, and degrades to plain content when JS is off:
*
* 1. Code blocks: a language label + copy button above every shiki <pre>.
* No-JS readers get the plain pre; the button simply never exists.
* 2. Figures: click-to-zoom lightbox on a native <dialog> (Esc and the
* focus trap come free; click anywhere closes). No-JS readers see the
* inline image.
* 3. Language memory: stores the last switcher choice and flags the
* reader's usual language on later posts — a nudge, never an
* auto-redirect: the reader always gets the page they asked for.
*
* Rendered as an invisible anchor inside <article>; it scopes all its DOM
* work to the closest article so unrelated pages stay untouched.
*/
export default function ArticleEnhance({ lang, languages }) {
const anchorRef = useRef(null);
useEffect(() => {
const article = anchorRef.current?.closest('article');
if (!article) return undefined;
const cleanups = [];
// --- code blocks: label + copy -----------------------------------
const copyLabel = lang === 'en' ? 'Copy' : 'Copier';
const copiedLabel = lang === 'en' ? 'Copied ✓' : 'Copié ✓';
article.querySelectorAll('pre[data-language]').forEach((pre) => {
if (pre.dataset.codebar) return;
pre.dataset.codebar = 'true';
const holder = pre.closest('[data-rehype-pretty-code-figure]') ?? pre.parentElement;
if (!holder) return;
const bar = document.createElement('div');
bar.className = 'prose-codebar';
const label = document.createElement('span');
label.className = 'prose-codelang';
label.textContent = pre.dataset.language || '';
const button = document.createElement('button');
button.type = 'button';
button.className = 'prose-copy';
button.textContent = copyLabel;
button.setAttribute('aria-label', `${copyLabel} (${pre.dataset.language})`);
button.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(pre.innerText);
button.textContent = copiedLabel;
} catch {
return; // clipboard refused — leave the button as it was
}
setTimeout(() => {
button.textContent = copyLabel;
}, 1600);
});
bar.append(label, button);
holder.prepend(bar);
cleanups.push(() => bar.remove());
});
// --- figures: lightbox --------------------------------------------
const dialog = document.createElement('dialog');
dialog.className = 'prose-lightbox';
dialog.addEventListener('click', () => dialog.close());
article.append(dialog);
cleanups.push(() => dialog.remove());
article.querySelectorAll('.prose-figure img').forEach((img) => {
const open = () => {
dialog.replaceChildren(img.cloneNode(true));
dialog.showModal();
};
img.addEventListener('click', open);
cleanups.push(() => img.removeEventListener('click', open));
});
// --- language switch memory ---------------------------------------
let stored = null;
try {
stored = window.localStorage.getItem('pln:blog-lang');
} catch {
// storage refused (private mode) — the switcher still works, unaided
}
article.querySelectorAll('[data-lang-switch] a[data-lang]').forEach((link) => {
const target = link.dataset.lang;
const remember = () => {
try {
window.localStorage.setItem('pln:blog-lang', target);
} catch {
// as above — best effort
}
};
link.addEventListener('click', remember);
cleanups.push(() => link.removeEventListener('click', remember));
if (stored && target === stored && languages?.includes(stored)) {
link.classList.add('langPreferred');
link.title = lang === 'en' ? 'Your usual language' : 'Votre langue habituelle';
}
});
return () => cleanups.forEach((fn) => fn());
}, [lang, languages]);
return <div ref={anchorRef} hidden aria-hidden="true" />;
}
import Link from "next/link"; import Link from "next/link";
import Layout from "@/components/layout"; import Layout from "@/components/layout";
import Date from "@/components/date"; import Date from "@/components/date";
import utilStyles from "@/styles/utils.module.css";
import styles from "./blog.module.css"; import styles from "./blog.module.css";
import { syne, serifBody } from "@/app/blog/article-fonts";
import { sansBody } from "@/app/blog/dev-fonts";
import { getBlogNeighbors } from "@/lib/blog";
import ArticleEnhance from "./ArticleEnhance";
import FontSwitcher from "./FontSwitcher";
/** /**
* The article markup shared by /blog/[slug], /blog/[slug]/[lang] and the * The article markup shared by /blog/[slug], /blog/[slug]/[lang] and the
...@@ -11,44 +15,133 @@ import styles from "./blog.module.css"; ...@@ -11,44 +15,133 @@ import styles from "./blog.module.css";
* *
* `switcher` / `banner` come from buildLanguageNav (language-nav.js) on the * `switcher` / `banner` come from buildLanguageNav (language-nav.js) on the
* public routes; the draft route passes neither (it ships exactly as before). * public routes; the draft route passes neither (it ships exactly as before).
* `post.toc` is minted at build time by the markdown chain; the pager is
* computed from the visible listing (drafts follow the prod/dev rule).
*
* Anatomy notes:
* - Layout is a grid of [TOC rail][core][margin band] at ≥80rem; below
* that it collapses to a single centered column (phone-first).
* - The TOC only ships for posts with ≥3 h2/h3; otherwise nothing.
* - Footnote margin clones come from the markdown chain; CSS decides
* whether they are margin notes, popovers or nothing (see blog.module.css).
* - Zero client JS is required to read: ArticleEnhance decorates code
* blocks, figures and the language switch; FontSwitcher is dev-only.
*/ */
const LABELS = {
fr: { toc: "Sommaire", pager: "Articles", older: "Plus ancien", newer: "Plus récent" },
en: { toc: "Contents", pager: "Posts", older: "Older", newer: "Newer" },
};
function TocList({ toc }) {
return (
<ol className={styles.tocList}>
{toc.map((entry) => (
<li key={entry.id} data-depth={entry.depth}>
<a href={`#${entry.id}`}>{entry.text}</a>
</li>
))}
</ol>
);
}
export default function PostArticle({ post, switcher, banner }) { export default function PostArticle({ post, switcher, banner }) {
const toc = post.toc ?? [];
const hasToc = toc.length >= 3;
const labels = LABELS[post.lang] ?? LABELS.fr;
const { older, newer } = getBlogNeighbors(post.slug, post.lang);
return ( return (
<Layout> <Layout>
<article className={styles.article}> <div className={styles.progress} aria-hidden="true" />
{switcher && switcher.length > 1 && ( {/* next/font CSS variables: brand display face + default body serif.
<nav className={styles.langSwitch} aria-label="Language"> The dev-only sans variable joins them when dev-fonts.js is real
{switcher.map((item, index) => ( (it is stubbed to null in production builds). */}
<span key={item.lang}> <article
{index > 0 && " / "} className={[styles.article, syne.variable, serifBody.variable, sansBody?.variable]
{item.lang === post.lang ? ( .filter(Boolean)
<b>{item.lang.toUpperCase()}</b> .join(" ")}
>
<div className={styles.shell}>
{hasToc && (
<nav className={styles.tocRail} aria-label={labels.toc}>
<div className={styles.tocSticky}>
<p className={styles.tocTitle}>{labels.toc}</p>
<TocList toc={toc} />
</div>
</nav>
)}
<div className={styles.core}>
{switcher && switcher.length > 1 && (
<nav className={styles.langSwitch} aria-label="Langue / Language" data-lang-switch>
{switcher.map((item, index) => (
<span key={item.lang}>
{index > 0 && " / "}
{item.lang === post.lang ? (
<b data-lang={item.lang}>{item.lang.toUpperCase()}</b>
) : (
<Link href={item.href} data-lang={item.lang}>
{item.lang.toUpperCase()}
</Link>
)}
</span>
))}
</nav>
)}
<h1 className={styles.title}>{post.title}</h1>
<div className={styles.meta}>
<Date dateString={post.date} />
{post.tags && post.tags.length > 0 && (
<ul className={styles.tags}>
{post.tags.map((tag) => (
<li key={tag}>#{tag}</li>
))}
</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>}
{hasToc && (
<details className={styles.tocDetails}>
<summary>{labels.toc}</summary>
<TocList toc={toc} />
</details>
)}
<div className={styles.prose} dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
{/* RELATED SEAM: post↔project threads slot in beside the pager
once /projects exists (DESIGN.md — the git-graph surface).
The pager keeps the date spine; related posts will hang off
the same nav. */}
{(older || newer) && (
<nav className={styles.pager} aria-label={labels.pager}>
{older ? (
<Link href={older.href} rel="prev" className={styles.pagerOlder}>
<span className={styles.pagerDir}>← {labels.older}</span>
{older.title}
</Link>
) : ( ) : (
<Link href={item.href}>{item.lang.toUpperCase()}</Link> <span />
)} )}
</span> {newer ? (
))} <Link href={newer.href} rel="next" className={styles.pagerNewer}>
</nav> <span className={styles.pagerDir}>{labels.newer} →</span>
)} {newer.title}
<h1 className={utilStyles.headingXl}>{post.title}</h1> </Link>
<div className={`${styles.meta} ${utilStyles.lightText}`}> ) : (
<Date dateString={post.date} /> <span />
{post.tags && post.tags.length > 0 && ( )}
<ul className={styles.tags}> </nav>
{post.tags.map((tag) => ( )}
<li key={tag}>#{tag}</li> </div>
))}
</ul>
)}
</div> </div>
{banner && ( <ArticleEnhance lang={post.lang} languages={post.languages} />
<p className={styles.untranslated}> <FontSwitcher />
{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> </article>
</Layout> </Layout>
); );
......
...@@ -20,6 +20,7 @@ export const syne = Syne({ ...@@ -20,6 +20,7 @@ export const syne = Syne({
subsets: ['latin'], subsets: ['latin'],
weight: ['700', '800'], weight: ['700', '800'],
display: 'swap', display: 'swap',
variable: '--font-syne',
}); });
export const serifBody = Source_Serif_4({ export const serifBody = Source_Serif_4({
...@@ -27,4 +28,5 @@ export const serifBody = Source_Serif_4({ ...@@ -27,4 +28,5 @@ export const serifBody = Source_Serif_4({
weight: ['400', '600', '700'], weight: ['400', '600', '700'],
style: ['normal', 'italic'], style: ['normal', 'italic'],
display: 'swap', display: 'swap',
variable: '--font-article-serif',
}); });
...@@ -20,4 +20,5 @@ export const sansBody = Atkinson_Hyperlegible({ ...@@ -20,4 +20,5 @@ export const sansBody = Atkinson_Hyperlegible({
weight: ['400', '700'], weight: ['400', '700'],
style: ['normal', 'italic'], style: ['normal', 'italic'],
display: 'swap', display: 'swap',
variable: '--font-article-sans',
}); });
const path = require('path'); const path = require('path');
const webpack = require('webpack');
module.exports = { module.exports = {
// Static export — the site ships as flat files (out/), served by self-hosted // Static export — the site ships as flat files (out/), served by self-hosted
...@@ -29,11 +30,14 @@ module.exports = { ...@@ -29,11 +30,14 @@ module.exports = {
config.resolve.alias['@'] = path.resolve(__dirname); config.resolve.alias['@'] = path.resolve(__dirname);
// The article body-face switcher (?fonts=) is a dev-only mockup surface. // The article body-face switcher (?fonts=) is a dev-only mockup surface.
// In production builds the alternative font module is swapped for a stub // In production builds the alternative font module is swapped for a stub
// so no second body pair is ever emitted into the shipped bundle. // (before alias resolution — an exact alias loses to the '@' prefix) so
// no second body pair is ever emitted into the shipped bundle.
if (!dev) { if (!dev) {
config.resolve.alias['@/app/blog/dev-fonts$'] = path.resolve( config.plugins.push(
__dirname, new webpack.NormalModuleReplacementPlugin(
'app/blog/dev-fonts.stub.js', /^@\/app\/blog\/dev-fonts$/,
'@/app/blog/dev-fonts.stub.js',
),
); );
} }
return config; return config;
......
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