Commit d0bb1436 by PLN (Algolia)

fmt: yarn fmt — black-like French typography autofix (fine insécable before !?;,…

fmt: yarn fmt — black-like French typography autofix (fine insécable before !?;, insécable before : and in « », frontmatter/code/links protected, idempotent) + applied to hello-world-again and labo
parent 7834c8ca
...@@ -2,11 +2,11 @@ ...@@ -2,11 +2,11 @@
title: Hello World (here we go again) title: Hello World (here we go again)
--- ---
Salut, Monde! Tu as bien changé. Moi aussi <3 Salut, Monde ! Tu as bien changé. Moi aussi <3
---- ----
Pourquoi écrire ? Pourquoi écrire ?
Parce que lire ne suffit pas. Parce que lire ne suffit pas.
Parce que ce qui se conçoit bien est plutôt cool. Parce que ce qui se conçoit bien est plutôt cool.
...@@ -15,7 +15,7 @@ toutes ces idées, atteriront bien quelque-part. ...@@ -15,7 +15,7 @@ toutes ces idées, atteriront bien quelque-part.
---- ----
Pourquoi publier ? Pourquoi publier ?
Parce que notre époque est celle d'une nouvelle alchimie. Parce que notre époque est celle d'une nouvelle alchimie.
Qu'il est bon de partager nos grimoires, nos Codex, d'inventer une nouvelle science, _ensemble_. Qu'il est bon de partager nos grimoires, nos Codex, d'inventer une nouvelle science, _ensemble_.
...@@ -23,13 +23,13 @@ Qu'il est bon de partager nos grimoires, nos Codex, d'inventer une nouvelle scie ...@@ -23,13 +23,13 @@ Qu'il est bon de partager nos grimoires, nos Codex, d'inventer une nouvelle scie
---- ----
Et puis pour l'art. Et puis pour l'art.
Pourquoi créer ? Pourquoi créer ?
Quel intérêt d'écrire un des milliards de textes du jour ? Quel intérêt d'écrire un des milliards de textes du jour ?
De prendre une des millions de photos du jour ? De prendre une des millions de photos du jour ?
De créer une des centaines de milliers de musiques ? De créer une des centaines de milliers de musiques ?
De peindre un des milliers de tableaux ? De peindre un des milliers de tableaux ?
Est-ce la rareté d'une fleur, ou la durée de sa croissance, ou la force de son parfum, qui fait sa valeur ? Est-ce la rareté d'une fleur, ou la durée de sa croissance, ou la force de son parfum, qui fait sa valeur ?
À quoi bon est le poison de l'âme. À quoi bon est le poison de l'âme.
Alors écrire. Alors écrire.
...@@ -4,10 +4,10 @@ title: Banc d'essai — médias riches ...@@ -4,10 +4,10 @@ title: Banc d'essai — médias riches
> **Brouillon agentique.** Ce billet est un banc d'essai écrit par l'agent pour > **Brouillon agentique.** Ce billet est un banc d'essai écrit par l'agent pour
> vérifier le pipeline — chaque bloc existe pour casser quelque chose. La prose > vérifier le pipeline — chaque bloc existe pour casser quelque chose. La prose
> sera réécrite à la main ; les blocs, eux, resteront. > sera réécrite à la main ; les blocs, eux, resteront.
Un blog que l'on reconstruit soi-même finit toujours par écrire sur sa propre Un blog que l'on reconstruit soi-même finit toujours par écrire sur sa propre
reconstruction. Voici donc le bilan de chantier : ce que le moteur rend, et reconstruction. Voici donc le bilan de chantier : ce que le moteur rend, et
comment. Tout est généré à la construction — la page livrée est du HTML plat, comment. Tout est généré à la construction — la page livrée est du HTML plat,
sans JavaScript indispensable.[^gfm] sans JavaScript indispensable.[^gfm]
...@@ -15,7 +15,7 @@ sans JavaScript indispensable.[^gfm] ...@@ -15,7 +15,7 @@ sans JavaScript indispensable.[^gfm]
Le surlignage passe par Shiki (thème *vesper*) pendant le build. Ce que vous Le surlignage passe par Shiki (thème *vesper*) pendant le build. Ce que vous
lisez est donc du HTML avec des couleurs inline — zéro coloriseur côté lisez est donc du HTML avec des couleurs inline — zéro coloriseur côté
navigateur : navigateur :
```ts ```ts
// lib/markdown.ts — la seule fabrique d'HTML de /blog // lib/markdown.ts — la seule fabrique d'HTML de /blog
...@@ -45,42 +45,42 @@ partout ailleurs — sans une ligne de JavaScript. Le clone de marge est fabriqu ...@@ -45,42 +45,42 @@ partout ailleurs — sans une ligne de JavaScript. Le clone de marge est fabriqu
à la construction depuis la définition GFM, la note de fin reste la source de à la construction depuis la définition GFM, la note de fin reste la source de
vérité pour les lecteurs d'écran.[^aria] vérité pour les lecteurs d'écran.[^aria]
[^gfm]: GitHub-Flavored Markdown, activé dans la chaîne via `remark-gfm`. [^gfm] : GitHub-Flavored Markdown, activé dans la chaîne via `remark-gfm`.
[^aria]: Les clones en marge sont `aria-hidden` ; la note de fin reste la [^aria] : Les clones en marge sont `aria-hidden` ; la note de fin reste la
version lisible par les lecteurs d'écran. version lisible par les lecteurs d'écran.
## Images et légendes ## Images et légendes
Un paragraphe réduit à une image devient une `<figure>` ; le texte alternatif Un paragraphe réduit à une image devient une `<figure>` ; le texte alternatif
sert de légende. Cliché de l'algorave de Lyon, 2025 : sert de légende. Cliché de l'algorave de Lyon, 2025 :
![Algorave de Lyon, 2025 — visuels Hydra, son TidalCycles](/images/blog/labo/poster.jpg)  ![Algorave de Lyon, 2025 — visuels Hydra, son TidalCycles](/images/blog/labo/poster.jpg)
## Le son ## Le son
Un petit accord de test, servi par le lecteur natif du navigateur (directive Un petit accord de test, servi par le lecteur natif du navigateur (directive
`::audio`, zéro client JS) : `::audio`, zéro client JS) :
::audio[/audio/labo/test-chord.mp3]{title="accord de test"}  ::audio[/audio/labo/test-chord.mp3]{title="accord de test"}
## Les embeds qui ne chargent rien ## Les embeds qui ne chargent rien
Le principe : rien de distant ne charge avant un clic. La façade est Le principe : rien de distant ne charge avant un clic. La façade est
construite à la construction ; le lecteur — YouTube *nocookie*, SoundCloud — construite à la construction ; le lecteur — YouTube *nocookie*, SoundCloud —
n'arrive que si vous l'appelez.[^facade] n'arrive que si vous l'appelez.[^facade]
::youtube[https://www.youtube.com/watch?v=dQw4w9WgXcQ]{title="Vidéo de test (remplacer)"}  ::youtube[https://www.youtube.com/watch?v=dQw4w9WgXcQ]{title="Vidéo de test (remplacer)"}
Et un set ParVagues, cette fois réel : Et un set ParVagues, cette fois réel :
::soundcloud[https://soundcloud.com/parvagues/live-39c3]{title="ParVagues — live 39C3"}  ::soundcloud[https://soundcloud.com/parvagues/live-39c3]{title="ParVagues — live 39C3"}
[^facade]: Seule exception : la vignette YouTube (une requête vers [^facade] : Seule exception : la vignette YouTube (une requête vers
`i.ytimg.com`, sans cookie). SoundCloud ne touche à rien du tout. `i.ytimg.com`, sans cookie). SoundCloud ne touche à rien du tout.
## Tableaux et notes ## Tableaux et notes
L'état du labo, en tableau GFM : L'état du labo, en tableau GFM :
| Média | Directive | JS client | Requête distante | | Média | Directive | JS client | Requête distante |
| ---------- | -------------- | --------- | ------------------- | | ---------- | -------------- | --------- | ------------------- |
...@@ -90,14 +90,14 @@ L'état du labo, en tableau GFM : ...@@ -90,14 +90,14 @@ L'état du labo, en tableau GFM :
| YouTube | `::youtube` | au clic | vignette + iframe | | YouTube | `::youtube` | au clic | vignette + iframe |
| SoundCloud | `::soundcloud` | au clic | iframe au clic | | SoundCloud | `::soundcloud` | au clic | iframe au clic |
:::note[À retenir]  : ::note[À retenir]
Tout ce qui est décoratif doit pouvoir disparaître : la page se lit Tout ce qui est décoratif doit pouvoir disparaître : la page se lit
entièrement sans JavaScript, chaque amélioration n'est qu'une décoration du entièrement sans JavaScript, chaque amélioration n'est qu'une décoration du
HTML déjà livré. HTML déjà livré.
:::  : : :
C'est le contrat de cette refonte : le lecteur sans JS reçoit l'article C'est le contrat de cette refonte : le lecteur sans JS reçoit l'article
complet, et chaque gadget n'arrive que pour les autres.[^contrat] complet, et chaque gadget n'arrive que pour les autres.[^contrat]
[^contrat]: Voir `ArticleEnhance.jsx` : une île invisible qui décore le DOM [^contrat] : Voir `ArticleEnhance.jsx` : une île invisible qui décore le DOM
déjà servi — copie de code, lightbox, mémoire de langue, façades. déjà servi — copie de code, lightbox, mémoire de langue, façades.
...@@ -17,7 +17,8 @@ ...@@ -17,7 +17,8 @@
"post:image": "node scripts/add-image.mjs", "post:image": "node scripts/add-image.mjs",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"prose": "node scripts/prose.mjs" "prose": "node scripts/prose.mjs",
"fmt": "node scripts/fmt.mjs"
}, },
"engines": { "engines": {
"node": ">=18.17.0" "node": ">=18.17.0"
......
#!/usr/bin/env node
/**
* yarn fmt [slug|file…] — opinionated, black-like typography autofix for
* content/blog. No config on purpose: like black, the formatting is the
* tool's, the words stay the author's.
*
* French rules (imprimerie nationale):
* - narrow no-break space (U+202F) before ! ? ;
* - no-break space (U+00A0) before :
* - no-break space just inside « » quotes
* English files are left untouched — en punctuation pacing is plain ASCII.
*
* Never touched: YAML frontmatter (structured data), code fences, inline
* code, markdown link/image destinations, bare URLs. Idempotent by design —
* the insécables are not in the [ \t] class the rules consume, so running
* twice changes nothing.
*/
import fs from 'node:fs';
import path from 'node:path';
const BLOG_ROOT = path.resolve(process.cwd(), 'content/blog');
/** Narrow no-break space before ! ? ; — the espace fine insécable. */
const FINE = '\u202f';
/** No-break space before : and inside « » — the espace insécable. */
const NBSP = '\u00a0';
const LANG_OF = (file) => {
const base = path.basename(file);
if (base === 'fr.md') return 'fr';
if (base === 'en.md') return 'en';
return null;
};
/** Protected prose forms: fences, inline code, link/image targets, URLs. */
const PROTECTED_RE =
/(^```[\s\S]*?^```[^\S\n]*$|^~~~[\s\S]*?^~~~[^\S\n]*$|`[^`\n]+`|\]\([^)\s]*\)|https?:\/\/\S+)/gm;
/**
* Split text into protected/unprotected segments. The French rules only run
* on unprotected segments; protected ones rejoin byte-for-byte.
*/
function segment(text) {
const segs = [];
let last = 0;
for (const m of text.matchAll(PROTECTED_RE)) {
if (m.index > last) segs.push({ prot: false, text: text.slice(last, m.index) });
segs.push({ prot: true, text: m[0] });
last = m.index + m[0].length;
}
if (last < text.length) segs.push({ prot: false, text: text.slice(last) });
return segs;
}
const FRENCH_RULES = [
// « salut » — insécables just inside the guillemets, added when absent
[new RegExp(`«(?!\\u00a0)[ \\t]*`, 'g'), `«${NBSP}`],
// the lookbehinds keep an already-insécable punctuation untouched —
// without them each pass would prepend another space (growth, not idempotence)
[new RegExp(`(?<!\\u00a0)[ \\t]*»`, 'g'), `${NBSP}»`],
// espace fine insécable before ! ? ; (normalize a plain space or add one)
[new RegExp(`(?<![\\u00a0\\u202f])[ \\t]*([!?;])(?!\\w)`, 'g'), `${FINE}$1`],
// espace insécable before : — but not in 12:30, 3:2, or a scheme://
[new RegExp(`(?<![\\u00a0\\u202f])[ \\t]*:(?![/\\w\\d])`, 'g'), `${NBSP}:`],
];
function fmtFrench(text) {
// The YAML frontmatter block is structured data, not prose.
const fm = text.match(/^---\n[\s\S]*?\n---\n/);
const head = fm ? fm[0] : '';
const body = text.slice(head.length);
return (
head +
segment(body)
.map((seg) =>
seg.prot ? seg.text : FRENCH_RULES.reduce((t, [re, rep]) => t.replace(re, rep), seg.text),
)
.join('')
);
}
function fmtFile(file) {
const lang = LANG_OF(file);
if (!lang) return null;
const original = fs.readFileSync(file, 'utf8');
const fixed = lang === 'fr' ? fmtFrench(original) : original;
if (fixed !== original) {
fs.writeFileSync(file, fixed);
return path.relative(process.cwd(), file);
}
return null;
}
// --- CLI -------------------------------------------------------------------
const args = process.argv.slice(2);
let files = [];
if (args.length === 0) {
files = fs
.readdirSync(BLOG_ROOT, { recursive: true })
.map((f) => path.join(BLOG_ROOT, f))
.filter((f) => fs.statSync(f).isFile() && f.endsWith('.md'));
} else {
for (const arg of args) {
const p = path.resolve(process.cwd(), arg);
if (fs.statSync(p).isDirectory()) {
files.push(
...fs
.readdirSync(p)
.map((f) => path.join(p, f))
.filter((f) => f.endsWith('.md')),
);
} else {
files.push(p);
}
}
}
const changed = files.map(fmtFile).filter(Boolean);
console.log(changed.length === 0 ? 'fmt: clean' : `fmt: ${changed.length} file(s) fixed`);
for (const f of changed) console.log(` ${f}`);
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