# Listings de code et touches de clavier

> Un guide du shell dont les blocs de code sont composés en encadrés sombres, colorés par jeton, avec les touches en pastilles de clavier.

- Version HTML: https://postext.dev/fr/cookbook/code-listings-and-keycaps
- Recette Nº 048 · Encadrés et notes · Niveau 2 (Intermédiaire) · Sorties: Canvas
- Genres: Manuels, guides et ouvrages de référence
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [49](https://postext.dev/cookbook/code-listings-and-keycaps/en/p01.webp?v=b3085fe5), [50](https://postext.dev/cookbook/code-listings-and-keycaps/en/p02.webp?v=b3085fe5), [51](https://postext.dev/cookbook/code-listings-and-keycaps/en/p03.webp?v=b3085fe5)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=code-listings-and-keycaps&lang=en (.postext: https://postext.dev/cookbook/code-listings-and-keycaps/en/code-listings-and-keycaps.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/code-listings-and-keycaps.md), [es](https://postext.dev/es/cookbook/code-listings-and-keycaps.md), [ca](https://postext.dev/ca/cookbook/code-listings-and-keycaps.md), [pt](https://postext.dev/pt/cookbook/code-listings-and-keycaps.md), [zh](https://postext.dev/zh/cookbook/code-listings-and-keycaps.md), [ja](https://postext.dev/ja/cookbook/code-listings-and-keycaps.md), [ar](https://postext.dev/ar/cookbook/code-listings-and-keycaps.md)

## En bref

Trois pages d’un guide de la ligne de commande pour débutants. La recette montre comment composer les exemples de code dans des encadrés sombres et colorés, et dessiner des touches comme Ctrl en petits boutons dans le texte.

## Ce que vous allez composer

Trois pages du chapitre 4 de *The Shell, Gently*, un guide de poche inventé de la ligne de commande, sur une page de 178 × 229 mm. Le texte est en Charis SIL justifié sur une colonne de 100 mm, avec une colonne de marge côté extérieur pour les notes. Chaque listing est un encadré presque noir en JetBrains Mono qui déborde dans cette colonne, sous un onglet portant le nom du fichier ou de la session. Les mots-clés et les commandes saisies s’impriment en ambre, les chaînes en vert et les commentaires en gris. Des touches comme Ctrl et Tab sont de petites touches dessinées au trait dans les lignes justifiées, et un aide-mémoire sur deux colonnes des raccourcis Ctrl remplit le pied de la page 50. Le Markdown garde des blocs de code délimités ordinaires, et le moteur les compose tels quels.

**Cette recette répond à:**

- Comment présenter des listings de code et des raccourcis clavier ?
- Comment faire des pastilles en ligne : touches de clavier, étiquettes, banques de mots pour des exercices ?
- Comment écrire des tirets de dialogue, des années en début de paragraphe, des prix et des symboles littéraux sans que le Markdown les interprète mal ?

## La réponse courte

````js
// script.js, lignes 26–41
// A ``` fence is a listing set line by line as written: indents, blank lines and the output
// columns of a session are kept. The rest of the fence line (Terminal, backup.sh) is its
// title; with a label set, it goes on the tab.
// The text after a listing goes in :::paragraphs{style="resume"}: flush, as after a heading.
const resume = { id: 'resume', firstLineIndent: ZERO };
const codeStyle = {
  fontFamily: MONO, fontSize: pt(8.6), lineHeight: pt(12.4), color: col('code'),
  background: col('night'), span: 'page', // across the text and the margin
  padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) },
  marginTop: mm(6), marginBottom: mm(2.5),
  label: { fontFamily: MONO, fontSize: pt(7), fontWeight: 700, color: col('phosphor'),
    background: col('night'), height: mm(5), offset: mm(5), paddingX: mm(3), // the tab
    position: 'top-left' },
  tokens: tokens(), // the colours, below
  inline: { fontSize: em(0.88) }, // `grep` in running text: the code face, no box
};
````

## Ingrédients

**Enseigne**

- [Listings de code](https://postext.dev/fr/docs/configuration-styles.md#listings-de-code): Les blocs délimités par ``` composés comme du code : ligne par ligne tels que saisis, dans un encadré, avec coloration syntaxique, numéros de ligne, lignes surlignées et lignes longues renvoyées à la ligne, composées plus petit ou coupées.
- [Pastilles dans le texte](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille): Des boîtes arrondies autour de mots, qui passent à la ligne d’un seul tenant et ne s’étirent jamais : touches de clavier, étiquettes, banques de mots, syllabes.
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré): Des styles d’encadré nommés pour les notes, les astuces et les avertissements : fond, bordure, rayon, bande, titre et leur propre typographie pour le corps et les listes.

**Utilise aussi**

- [Onglets numérotés d’encadré](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Colonnes dans un encadré ou dans le texte](https://postext.dev/fr/docs/document-format.md#columns)
- [Encadrés sur toute la largeur](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Encadrés flottants](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Notes marginales](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Colonne et demie](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page)
- [Colonne marginale pour les flottants](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page)
- [Gras, italique et leurs couleurs](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Listes numérotées](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Coupure optimale des lignes (Knuth–Plass)](https://postext.dev/fr/docs/justification.md#knuth-plass-voir-le-paragraphe-entier)
- [Mathématiques](https://postext.dev/fr/docs/document-format.md#formules-mathématiques)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`chipStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille), `codeStyle`, [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`orderedLists`](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)

**API**

- [`buildDocument`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#construire-un-document), [`prepareFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`withLoadedFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page)

**Caractères**

- Charis SIL (OFL-1.1), Sora (OFL-1.1), JetBrains Mono (OFL-1.1)

## Préparation

### 1 · Donner un encadré aux blocs délimités

Le code de cette étape est [la réponse courte](https://postext.dev/fr/cookbook/code-listings-and-keycaps.md#la-réponse-courte) ci-dessus. Chaque bloc délimité par ```` ``` ```` dans le Markdown est un [bloc de code](https://postext.dev/fr/docs/document-format.md#blocs-de-code) : Postext compose ses lignes telles qu’elles sont saisies, une ligne de code par ligne composée, en gardant les retraits, les lignes vides du script et la sortie sur deux colonnes de `du`. `codeStyle` dessine tous les listings d’un coup ([listings de code](https://postext.dev/fr/docs/configuration-styles.md#listings-de-code)) : le caractère et son interlignage, le fond presque noir, la marge intérieure et l’espace au-dessus et au-dessous. Ce qui suit le langage sur la ligne d’ouverture (`Terminal`, `backup.sh`) est le titre du listing, et comme `codeStyle.label` est réglé, il s’imprime sur un [onglet d’étiquette](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré) au bord supérieur de l’encadré plutôt que dans une rangée de titre. `codeStyle.inline` compose le code en ligne du texte courant, `ls` ou `grep -c`, dans le même caractère à 0,88 em ([code en ligne](https://postext.dev/fr/docs/configuration-styles.md#code-en-ligne)). Le paragraphe qui suit un listing va dans `:::paragraphs{style="resume"}`, si bien qu’il commence au fer, comme après un titre.

### 2 · Garder le texte étroit et laisser le code traverser la marge

```js
// script.js, lignes 124–127
  page: { sizePreset: 'custom', width: mm(178), height: mm(229), margins: {
    top: mm(22), bottom: mm(21), left: mm(20), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: 26, gutterWidth: mm(6),
    sideColumnRole: 'floats', sideColumnSide: 'outer' },
```

À 10 pt, Charis SIL compose environ 62 caractères dans la colonne de 100 mm. La colonne latérale de cette [mise en page à une colonne et demie](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page) ne reçoit que des flottants et des notes, si bien que le texte n’y entre jamais. `span: 'page'` dans `codeStyle` étire chaque listing sur les deux colonnes, soit 143 mm. Dans sa marge intérieure, un encadré contient 73 caractères de JetBrains Mono à 8,6 pt ; la ligne la plus longue de ces pages, le commentaire qui ouvre `backup.sh`, en compte 71.

### 3 · Colorer le code par jeton

```js
// script.js, lignes 45–54
function tokens() {
  const plain = { color: col('code') }; // every other kind in the listing's own colour
  return {
    keyword: { color: col('amber'), bold: true }, function: { color: col('amber'), bold: true },
    string: { color: col('phosphor'), italic: true },
    comment: { color: col('slate'), italic: false }, // upright: the default comment is italic
    prompt: { color: col('amber'), bold: true }, output: plain, // what you type, the answer
    number: plain, type: plain, variable: plain, meta: plain, operator: plain, punctuation: plain,
  };
}
```

Le langage indiqué sur la ligne d’ouverture (`console`, `bash`) choisit l’analyseur intégré, et `codeStyle.tokens` donne à chaque type de jeton sa couleur, sa graisse et son inclinaison ([coloration syntaxique](https://postext.dev/fr/docs/configuration-styles.md#coloration-syntaxique)). Dans `backup.sh`, les mots-clés comme `for` et `done` et les commandes internes comme `set` et `echo` s’impriment en gras ambre, les chaînes en italique vert et les commentaires en gris ; les programmes comme `mkdir` et `rsync` restent sans couleur. Le style par défaut compose les commentaires en italique, d’où `italic: false` pour les garder droits. Dans un bloc `console`, une ligne qui commence par une invite est un jeton `prompt` et toute autre ligne est `output`, si bien que les commandes que vous saisissez s’impriment en gras ambre et que la réponse du shell reste dans le gris clair du listing. Les types que la recette ne veut pas colorer, dont les nombres, les variables et les options, prennent eux aussi ce gris.

### 4 · Composer les touches en pastilles

```js
// script.js, lignes 58–65
// Chips never break or stretch, so a line with keys puts all its slack in its word spaces;
// the breaker tries other breaks before a space passes 140 % (default 200 %).
const spacing = { maxWordSpacing: 1.4 }; // spread into bodyText
const chipStyles = [
  { id: 'key', fontFamily: MONO, fontSize: pt(7.8), bold: true, color: col('ink'),
    background: col('code'), borderColor: col('slate'), borderWidth: pt(0.6),
    borderRadius: pt(1.6), paddingX: em(0.45), paddingY: em(0.14), gap: em(0.3) },
];
```

Les touches sont des pastilles `key` avec un fond clair et un contour de 0,6 pt. Leur taille est donnée en points, si bien qu’une touche mesure 7,8 pt dans le texte de 10 pt, dans la note marginale de 8,6 pt et dans l’aide-mémoire de 8,4 pt ; `em(0.78)` réduirait les touches de l’aide-mémoire à 6,6 pt. Une pastille ne se coupe jamais d’une ligne à l’autre et ne s’étire jamais ([pastilles en ligne](https://postext.dev/fr/docs/document-format.md#pastilles-en-ligne)), si bien qu’une ligne qui contient des touches met tout son jeu dans ses espaces entre les mots. `spacing` fait essayer d’autres coupures à l’algorithme de coupure des lignes avant qu’une espace ne dépasse 140 % de sa largeur normale. Avec la valeur par défaut, 200 %, six lignes de ces pages s’étirent davantage ; avec ce réglage, la plus large atteint 132 %.

### 5 · Numéroter les étapes sur la grille

```js
// script.js, lignes 91–94
const orderedLists = { fontFamily: DISPLAY, fontWeight: 800, color: col('ember'),
  gap: em(0.7), separator: '›', separatorGap: em(0.25), separatorFontFamily: MONO,
  separatorFontWeight: 700, separatorColor: col('muted'),
  marginTop: ZERO, marginBottom: ZERO }; // the default 1.5 em opens 5.3 mm above and below
```

Les numéros sont en Sora 800 dans la couleur d’accent, et le séparateur est un `›` en chasse fixe gris. Le séparateur a son propre caractère et sa propre couleur, si bien qu’il est dessiné comme un passage à part ([listes numérotées](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées)). Les marges de la liste sont nulles, si bien que les étapes continuent sur la grille de 14,5 pt du texte qui les entoure. Les 1,5 em par défaut ouvriraient 5,3 mm au-dessus des étapes et repousseraient l’aide-mémoire en page 51.

### 6 · Faire flotter l’aide-mémoire au pied de la page

```js
// script.js, lignes 69–76
const sheet = { id: 'sheet', background: col('night'), span: 'page', placement: 'bottom',
  marginTop: mm(6), marginBottom: mm(2.5),
  columnGap: mm(8), padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { fontFamily: MONO, fontSize: pt(7.5), fontWeight: 700, gap: mm(3.5),
    color: col('phosphor'), textTransform: 'uppercase', letterSpacing: pt(1.5) },
  body: { fontFamily: DISPLAY, fontSize: pt(8.4), lineHeight: pt(13), textAlign: 'left',
    color: col('code'), boldColor: col('amber'), italicColor: col('phosphor'),
    paragraphSpacing: false, firstLineIndent: ZERO } };
```

L’aide-mémoire est un style d’encadré avec le fond des listings, Sora pour les actions, et `placement: 'bottom'`, qui le fait flotter au pied de la page 50 ([encadrés flottants](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)). Laissé dans le flux, un encadré sur toute la largeur de la page a besoin de place pour lui-même et pour deux lignes de texte en dessous. Ici, l’encadré passerait en page 51, laissant 58 mm vides sous les étapes, et le chapitre s’étendrait sur quatre pages. Dans le Markdown, `:::columns{count=2 breaks="8"}` ouvre la colonne de droite au huitième bloc, son intertitre. L’équilibrage seul coupe selon la hauteur, et quand une action déborde sur une deuxième ligne il laisse *Commands and history* au pied de la colonne de gauche. Chaque entrée commence par les deux mêmes pastilles, `Ctrl` et une lettre, toutes deux en chasse fixe, si bien que chaque action commence à la même distance du bord gauche de sa colonne.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: https://github.com/drnachio/postext/tree/main/cookbook/code-listings-and-keycaps

### script.js

````js
// ═══ Postext Cookbook · Nº 048 · Code listings and keycaps ═══
// https://postext.dev/en/cookbook/code-listings-and-keycaps
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Charis SIL, Sora, JetBrains Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocument, prepareFonts, withLoadedFonts, renderPageToCanvas,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'code-listings-and-keycaps';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#1b1f24', muted: '#5c636b', ember: '#9a5410', // text, heads, accent
  night: '#0e1116', code: '#d3d9df', amber: '#f2b134', phosphor: '#3ddc84', // the listings
  slate: '#8a939d' }; // comments in a listing, the outline of a key (code is its face)
// Every colour is linked to its palette entry by id.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ember })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, MONO] = ['Charis SIL', 'Sora', 'JetBrains Mono'];
const LEAD = 14.5; // pt: the body leading, the page's baseline grid
const ZERO = pt(0);

// #region answer: a fence is a code listing; codeStyle gives it a dark box
// A ``` fence is a listing set line by line as written: indents, blank lines and the output
// columns of a session are kept. The rest of the fence line (Terminal, backup.sh) is its
// title; with a label set, it goes on the tab.
// The text after a listing goes in :::paragraphs{style="resume"}: flush, as after a heading.
const resume = { id: 'resume', firstLineIndent: ZERO };
const codeStyle = {
  fontFamily: MONO, fontSize: pt(8.6), lineHeight: pt(12.4), color: col('code'),
  background: col('night'), span: 'page', // across the text and the margin
  padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) },
  marginTop: mm(6), marginBottom: mm(2.5),
  label: { fontFamily: MONO, fontSize: pt(7), fontWeight: 700, color: col('phosphor'),
    background: col('night'), height: mm(5), offset: mm(5), paddingX: mm(3), // the tab
    position: 'top-left' },
  tokens: tokens(), // the colours, below
  inline: { fontSize: em(0.88) }, // `grep` in running text: the code face, no box
};
// #endregion

// #region paint: keywords amber, strings green, comments slate; a session's typed lines amber
function tokens() {
  const plain = { color: col('code') }; // every other kind in the listing's own colour
  return {
    keyword: { color: col('amber'), bold: true }, function: { color: col('amber'), bold: true },
    string: { color: col('phosphor'), italic: true },
    comment: { color: col('slate'), italic: false }, // upright: the default comment is italic
    prompt: { color: col('amber'), bold: true }, output: plain, // what you type, the answer
    number: plain, type: plain, variable: plain, meta: plain, operator: plain, punctuation: plain,
  };
}
// #endregion

// #region keycaps: keys are chips
// Chips never break or stretch, so a line with keys puts all its slack in its word spaces;
// the breaker tries other breaks before a space passes 140 % (default 200 %).
const spacing = { maxWordSpacing: 1.4 }; // spread into bodyText
const chipStyles = [
  { id: 'key', fontFamily: MONO, fontSize: pt(7.8), bold: true, color: col('ink'),
    background: col('code'), borderColor: col('slate'), borderWidth: pt(0.6),
    borderRadius: pt(1.6), paddingX: em(0.45), paddingY: em(0.14), gap: em(0.3) },
];
// #endregion

// #region sheet: a two-column cheat sheet floated to the foot of its page
const sheet = { id: 'sheet', background: col('night'), span: 'page', placement: 'bottom',
  marginTop: mm(6), marginBottom: mm(2.5),
  columnGap: mm(8), padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { fontFamily: MONO, fontSize: pt(7.5), fontWeight: 700, gap: mm(3.5),
    color: col('phosphor'), textTransform: 'uppercase', letterSpacing: pt(1.5) },
  body: { fontFamily: DISPLAY, fontSize: pt(8.4), lineHeight: pt(13), textAlign: 'left',
    color: col('code'), boldColor: col('amber'), italicColor: col('phosphor'),
    paragraphSpacing: false, firstLineIndent: ZERO } };
// #endregion

const aside = { id: 'aside', span: 'side', backgroundEnabled: false, // notes in the margin
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('ember') },
  padding: { top: mm(2.2), right: ZERO, bottom: ZERO, left: ZERO },
  titleStyle: { fontFamily: MONO, fontSize: pt(7.5), fontWeight: 700, color: col('ember'),
    textTransform: 'uppercase', letterSpacing: pt(1.2), gap: mm(1.2) },
  body: { fontFamily: TEXT, fontSize: pt(8.6), lineHeight: pt(12.5), textAlign: 'left',
    firstLineIndent: ZERO } };
const colophon = { ...aside, id: 'colophon', stripe: { enabled: false }, body: { ...aside.body,
  fontFamily: MONO, fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted'),
  italicColor: col('muted') } };

// #region steps: numbered steps on the grid, a prompt sign for a separator
const orderedLists = { fontFamily: DISPLAY, fontWeight: 800, color: col('ember'),
  gap: em(0.7), separator: '›', separatorGap: em(0.25), separatorFontFamily: MONO,
  separatorFontWeight: 700, separatorColor: col('muted'),
  marginTop: ZERO, marginBottom: ZERO }; // the default 1.5 em opens 5.3 mm above and below
// #endregion

const OUTER = 15; // mm: the outer margin; the running heads align to it
const text = (id, content, family, size, look, placement) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), placement, ...look,
  align: 'left' }); // design text is centred by default; an opener wraps its text
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { x: ZERO, y: mm(y) }, size: { width } });
const opener = { enabled: true, slot: { elements: [
  text('kicker', '{attr.kicker}', MONO, 8, { fontWeight: 700, letterSpacing: pt(1.6),
    textTransform: 'uppercase', color: col('ember') },
  { anchor: { to: 'container', edge: 'top-left' }, offset: { x: ZERO, y: mm(4) } }),
  text('title', '{titleText}', DISPLAY, 33, { fontWeight: 800, lineHeight: 1.04 },
    below('kicker', 3.5, mm(118))),
  text('lead', '{attr.lead}', TEXT, 12, { italic: true, lineHeight: 1.36 },
    below('title', 5, 'fill')),
] } };
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: MONO, fontSize: pt(7.5),
  letterSpacing: pt(1.1), textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...extra,
});
const folio = { fontWeight: 700, color: col('ember') };

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // the hyphenation patterns of each edition
  colorPalette, chipStyles, orderedLists, paragraphStyles: [resume],
  calloutStyles: [sheet, aside, colophon], codeStyle,
  // #region page: a text column and a margin column that only listings and notes enter
  page: { sizePreset: 'custom', width: mm(178), height: mm(229), margins: {
    top: mm(22), bottom: mm(21), left: mm(20), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: 26, gutterWidth: mm(6),
    sideColumnRole: 'floats', sideColumnSide: 'outer' },
  // #endregion
  bodyText: { ...spacing, // keycaps
    fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4.5), indentAfterHeading: false },
  headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 800, levels: [
    // parity 'odd': the next recto; the default 'always-odd' also leaves a blank page before it.
    { level: 1, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener },
    { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: ZERO },
  ] },
  header: { elements: [
    head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
    head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
    head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
    head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
  ] },
  footer: { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
    ...folio, pages: 'opener', // the opener has no running head: its folio drops to the foot
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { x: ZERO, y: mm(9) } } })] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = `---
title: "The Shell, Gently"
subtitle: "A pocket guide to the command line"
author: "Tove Ahlberg"
---

# Small tools, joined {kicker="Chapter 4" lead="How the pipe character chains programs that each do one job to answer a question about a folder."}

Each program in this chapter does one job. \`ls\` lists the names in a folder, \`grep\` keeps the lines that match a pattern, \`sort\` puts lines in order and \`du\` reports how much disk space a file takes. The pipe, the \`|\` character, sends whatever one program prints into the next one, so you can chain them on a single line and read the answer at the end.

:::callout{type="aside" title="The prompt"}
On a Mac, zsh prints \`%\` instead of \`$\`.
:::

Try it in a folder of photographs. In the listings, a line that starts with a dollar sign is one you type, leaving out the dollar, and run with :chip[Enter]{style="key"}. The dollar is the prompt, which the shell prints to show it is waiting for you. The lines under it are the shell’s answer.

\`\`\`console Terminal
$ cd ~/Pictures/2025
$ ls | grep -c 'JPG$'
268
$ ls | grep -v 'JPG$'
IMG_0413.MOV
IMG_0977.MOV
IMG_1502.PNG
$ du -sh *.MOV | sort -rh
812M    IMG_0977.MOV
455M    IMG_0413.MOV
\`\`\`

:::paragraphs{style="resume"}
\`grep -c\` counts the matching lines instead of printing them, and \`-v\` keeps the lines that do not match. The single quotes hand the pattern to \`grep\` as typed; in it, \`$\` marks the end of the line. The star in the last command belongs to the shell: before \`du\` starts, \`*.MOV\` is already the list of names ending in \`.MOV\`.
:::

:::callout{type="aside" title="On a Mac"}
:chip[Ctrl]{style="key"} is :chip[control]{style="key"}

:chip[Enter]{style="key"} is :chip[return]{style="key"}

Shortcuts use :chip[control]{style="key"}, not :chip[command]{style="key"}.
:::

## When a command will not stop

Sooner or later you will start a command that does not finish. Type \`grep JPG\` with no file after it, and \`grep\` sits waiting for you to type the lines it should search. To stop it, hold :chip[Ctrl]{style="key"} and press :chip[C]{style="key"}; the prompt comes back and nothing has changed. To end its input properly instead, press :chip[Ctrl]{style="key"} :chip[D]{style="key"} at the start of an empty line; \`grep\` reads it as the end of its input. :chip[Ctrl]{style="key"} :chip[C]{style="key"} also stops a \`ping\`, which would otherwise print a line every second until you close the window.

The shell also saves you typing. After the first letters of a file or folder name, press :chip[Tab]{style="key"} and the shell fills in the rest; when more than one name fits, it lists them (bash waits for a second :chip[Tab]{style="key"}). :chip[↑]{style="key"} brings back the last command, and each press goes one further back, so a pipeline with a typo can be mended instead of typed again.

1. Type \`cd ~/Pic\` and press :chip[Tab]{style="key"} to complete the folder name, \`Pictures/\`, then add \`2025\` and press :chip[Enter]{style="key"}.
2. Press :chip[↑]{style="key"} until \`ls | grep -v 'JPG$'\` is back on the line.
3. Hold :chip[Ctrl]{style="key"} and press :chip[A]{style="key"} to jump to the start of the line, then :chip[Ctrl]{style="key"} :chip[E]{style="key"} to return to the end.
4. Type \`| sort -r\` and press :chip[Enter]{style="key"}. The same names come back in reverse order.

:::callout{type="sheet" title="Cheat sheet · bash and zsh"}
:::columns{count=2 breaks="8"}
**On the line**

:chip[Ctrl]{style="key"} :chip[A]{style="key"} start of the line

:chip[Ctrl]{style="key"} :chip[E]{style="key"} end of the line

:chip[Ctrl]{style="key"} :chip[W]{style="key"} cut the word to the left

:chip[Ctrl]{style="key"} :chip[K]{style="key"} cut to the end of the line

:chip[Ctrl]{style="key"} :chip[Y]{style="key"} paste what you cut

:chip[Ctrl]{style="key"} :chip[T]{style="key"} swap two letters

**Commands and history**

:chip[Ctrl]{style="key"} :chip[R]{style="key"} search earlier commands

:chip[Ctrl]{style="key"} :chip[P]{style="key"} the previous command

:chip[Ctrl]{style="key"} :chip[C]{style="key"} stop the running command

:chip[Ctrl]{style="key"} :chip[Z]{style="key"} pause it; \`fg\` resumes it

:chip[Ctrl]{style="key"} :chip[L]{style="key"} clear the screen

:chip[Ctrl]{style="key"} :chip[D]{style="key"} close the shell (empty line)
:::
:::

## A script to keep

Commands you type every week are worth keeping in a file. The one below copies each folder in Documents to an external disk, into a new folder named after the day’s date. Save it as \`backup.sh\` in your home folder.

\`\`\`bash backup.sh
#!/usr/bin/env bash
# Copy each folder in ~/Documents to a dated folder on the backup disk.
set -euo pipefail

src="$HOME/Documents"
dest="/Volumes/Backup/$(date +%F)"

mkdir -p "$dest"
for dir in "$src"/*/; do
  name=$(basename "$dir")
  rsync -a "$dir" "$dest/$name/"
  echo "copied $name"
done
\`\`\`

:::callout{type="aside" title="Archive mode"}
\`rsync -a\` copies the subfolders too and keeps each file’s dates and permissions.
:::

:::paragraphs{style="resume"}
The first line, the *shebang*, names the program that runs the file. \`set -euo pipefail\` stops the script at the first command that fails, so it never carries on with half a backup. \`$(date +%F)\` runs \`date\` and puts what it prints, such as 2026-09-26, into the path. The quotes round each variable keep a folder called My Taxes in one piece; without them the shell would split the name at the space and \`rsync\` would look for two folders that do not exist.
:::

Run \`chmod +x backup.sh\` once to make the file executable, then start it with \`./backup.sh\`. On Linux an external disk usually appears under \`/media\`, in a folder named after your user, so change the \`dest\` line to match.

:::callout{type="colophon"}
*The Shell, Gently* is a fictional book written for the Postext Cookbook. Set in Charis SIL, Sora and JetBrains Mono (SIL OFL). Text: original, CC BY 4.0.
:::

Chapter 5 points \`grep\` at the log files under \`/var/log\`, where a pipeline of three commands counts how many errors the system logged on each day of the past week.
`; // content.<lang>.md, inlined by the Cookbook
const continuation = { pageIndexOffset: 48, pageNumbering: { startAt: 49 } }; // p. 49, a recto

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Charis SIL': ['400', '400i'], Sora: ['400', '700', '800'],
  'JetBrains Mono': ['400', '400i', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await prepareFonts(markdown, config(), kitFonts(FONTS));
const build = () => buildDocument({ markdown, continuation }, config());
const doc = await withLoadedFonts(build, { ...kitFonts(FONTS), text: markdown });
showPages(doc, { title: t({ en: 'The Shell, Gently', es: 'La terminal, con calma' }) });

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
````

## Variantes

### Accrocher l’onglet à droite

L’onglet passe au coin supérieur droit de l’encadré, qui sur un recto se trouve au-dessus de la colonne de marge.

```diff
-    position: 'top-left' },
+    position: 'top-right' },
```

### Numéroter les lignes

Chaque listing reçoit des numéros de ligne en gris dans une gouttière à l’intérieur de son encadré. La gouttière prend environ quatre des 73 caractères, si bien que le commentaire qui ouvre `backup.sh` ne tient plus : il passe à la ligne après `backup`, et `disk.` continue en dessous derrière un `»`. La vérification du livre de recettes le signale par un avertissement `codeOverflow` ; raccourcissez le commentaire pour garder le script sur ses 13 lignes. Un listing isolé prend des numéros avec `{lineNumbers}` sur sa ligne d’ouverture.

```diff
+  lineNumbers: true, lineNumberColor: col('slate'),
   tokens: tokens(), // the colours, below
```

### Donner aux commentaires la couleur des chaînes

Les commentaires prennent le vert des chaînes, en italique.

```diff
-    comment: { color: col('slate'), italic: false }, // upright: the default comment is italic
+    comment: { color: col('phosphor'), italic: true }, // the strings' green italic
```

## Erreurs fréquentes

- **Un encadré latéral commence au niveau du bloc qui suit sa clôture.** Dans postext 1.4.1, un encadré span: 'side' se place dans la colonne latérale à la hauteur atteinte par le texte à sa clôture, sur la ligne de grille suivante, et sous tout encadré déjà présent. Placez une glose juste avant le paragraphe qu’elle commente : placée après, elle commence en face du paragraphe suivant. Un encadré qui dépasserait le pied de la colonne remonte jusqu’à ce que son pied repose sur celui de la colonne, autant que le permet l’encadré au-dessus ; s’il ne tient toujours pas, il attend la colonne latérale de la page suivante.

- Limitez chaque ligne de code à 73 caractères. Une ligne plus longue passe à la ligne à sa dernière espace ou au dernier signe de ponctuation qui tient, continue en dessous derrière un `»`, et le moteur signale un avertissement `codeOverflow` ; `overflow: 'shrink'` compose plutôt tout le listing plus petit, et `'clip'` coupe la ligne au bord de l’encadré.
- Une tabulation dans un listing avance jusqu’au multiple de quatre colonnes suivant (`codeStyle.tabSize`). Un script indenté par tabulations que votre éditeur affiche sur huit colonnes sort ici plus étroit.
- Avec `codeStyle.inline` réglé, le code en ligne ne se coupe jamais d’une ligne à l’autre, comme une pastille. Un long chemin dans le texte courant, comme `~/Pictures/2025/IMG_0977.MOV`, passe entier à la ligne suivante et peut laisser une ligne lâche derrière lui.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caractères: Charis SIL (OFL-1.1), Sora (OFL-1.1), JetBrains Mono (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 050 · Notice de produit avec consignes de sécurité](https://postext.dev/fr/cookbook/product-manual-warnings.md): La notice allemande d’une bouilloire, dont les encadrés WARNUNG et VORSICHT portent le triangle sur un bandeau de couleur de signal, avec légendes allemandes. · Niveau 2 (Intermédiaire) · Manuels, guides et ouvrages de référence
- [Nº 008 · Une famille d’encadrés de manuel codée par couleur](https://postext.dev/fr/cookbook/textbook-box-family.md): Six sortes d’encadrés de manuel en trois couleurs, distinguées par une bande à icône, un badge, un onglet numéroté, des pictogrammes ou un repère hors cadre. · Niveau 2 (Intermédiaire) · Manuels scolaires
- [Nº 022 · Fiche d’exercices avec cases réponse et banque de mots](https://postext.dev/fr/cookbook/worksheet-answer-boxes.md): Une fiche de sciences de quatre pages : cases réponse blanches dans des cartes vert pâle, 2 mm sous chaque question, hors grille ; mots et blancs en pastilles. · Niveau 2 (Intermédiaire) · Cahiers et exercices
