Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
W
www
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
1
Issues
1
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
PLN
www
Commits
12387c68
Commit
12387c68
authored
Oct 07, 2026
by
PLN (Algolia)
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
blog: article page anatomy — TOC rail, margin notes, code bars, lightbox, pager
parent
4fa6d331
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
219 additions
and
11 deletions
+219
-11
ArticleEnhance.jsx
app/blog/ArticleEnhance.jsx
+108
-0
PostArticle.jsx
app/blog/PostArticle.jsx
+100
-7
article-fonts.js
app/blog/article-fonts.js
+2
-0
blog.module.css
app/blog/blog.module.css
+0
-0
dev-fonts.js
app/blog/dev-fonts.js
+1
-0
next.config.js
next.config.js
+8
-4
No files found.
app/blog/ArticleEnhance.jsx
0 → 100644
View file @
12387c68
'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"
/>;
}
app/blog/PostArticle.jsx
View file @
12387c68
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,27 +15,81 @@ import styles from "./blog.module.css";
...
@@ -11,27 +15,81 @@ 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"
/>
{
/* next/font CSS variables: brand display face + default body serif.
The dev-only sans variable joins them when dev-fonts.js is real
(it is stubbed to null in production builds). */
}
<
article
className=
{
[
styles
.
article
,
syne
.
variable
,
serifBody
.
variable
,
sansBody
?.
variable
]
.
filter
(
Boolean
)
.
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
&&
(
{
switcher
&&
switcher
.
length
>
1
&&
(
<
nav
className=
{
styles
.
langSwitch
}
aria
-
label=
"Language"
>
<
nav
className=
{
styles
.
langSwitch
}
aria
-
label=
"Langue / Language"
data
-
lang
-
switch
>
{
switcher
.
map
((
item
,
index
)
=>
(
{
switcher
.
map
((
item
,
index
)
=>
(
<
span
key=
{
item
.
lang
}
>
<
span
key=
{
item
.
lang
}
>
{
index
>
0
&&
" / "
}
{
index
>
0
&&
" / "
}
{
item
.
lang
===
post
.
lang
?
(
{
item
.
lang
===
post
.
lang
?
(
<
b
>
{
item
.
lang
.
toUpperCase
()
}
</
b
>
<
b
data
-
lang=
{
item
.
lang
}
>
{
item
.
lang
.
toUpperCase
()
}
</
b
>
)
:
(
)
:
(
<
Link
href=
{
item
.
href
}
>
{
item
.
lang
.
toUpperCase
()
}
</
Link
>
<
Link
href=
{
item
.
href
}
data
-
lang=
{
item
.
lang
}
>
{
item
.
lang
.
toUpperCase
()
}
</
Link
>
)
}
)
}
</
span
>
</
span
>
))
}
))
}
</
nav
>
</
nav
>
)
}
)
}
<
h1
className=
{
utilStyles
.
headingXl
}
>
{
post
.
title
}
</
h1
>
<
h1
className=
{
styles
.
title
}
>
{
post
.
title
}
</
h1
>
<
div
className=
{
`${styles.meta} ${utilStyles.lightText}`
}
>
<
div
className=
{
styles
.
meta
}
>
<
Date
dateString=
{
post
.
date
}
/>
<
Date
dateString=
{
post
.
date
}
/>
{
post
.
tags
&&
post
.
tags
.
length
>
0
&&
(
{
post
.
tags
&&
post
.
tags
.
length
>
0
&&
(
<
ul
className=
{
styles
.
tags
}
>
<
ul
className=
{
styles
.
tags
}
>
...
@@ -48,7 +106,42 @@ export default function PostArticle({ post, switcher, banner }) {
...
@@ -48,7 +106,42 @@ export default function PostArticle({ post, switcher, banner }) {
</
p
>
</
p
>
)
}
)
}
{
post
.
description
&&
<
p
className=
{
styles
.
description
}
>
{
post
.
description
}
</
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
}
}
/>
<
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
>
)
:
(
<
span
/>
)
}
{
newer
?
(
<
Link
href=
{
newer
.
href
}
rel=
"next"
className=
{
styles
.
pagerNewer
}
>
<
span
className=
{
styles
.
pagerDir
}
>
{
labels
.
newer
}
→
</
span
>
{
newer
.
title
}
</
Link
>
)
:
(
<
span
/>
)
}
</
nav
>
)
}
</
div
>
</
div
>
<
ArticleEnhance
lang=
{
post
.
lang
}
languages=
{
post
.
languages
}
/>
<
FontSwitcher
/>
</
article
>
</
article
>
</
Layout
>
</
Layout
>
);
);
...
...
app/blog/article-fonts.js
View file @
12387c68
...
@@ -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'
,
});
});
app/blog/blog.module.css
View file @
12387c68
This diff is collapsed.
Click to expand it.
app/blog/dev-fonts.js
View file @
12387c68
...
@@ -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'
,
});
});
next.config.js
View file @
12387c68
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
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment