Aller au contenu principal
Recette numéro 48

Recettes · Chapitre 6 · Encadrés et notes

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.

p. 50–51 · 2–3 sur 3

  • Exemple en anglais : pas encore d’édition française
  • Format 178 × 229 mm
  • Colonne et demie, Gouttière de 6 mm
  • Charis SIL 10/14,5
  • JetBrains Mono
  • Sora
  • 3 pages
  • Niveau
  • Postext 1.29.1
  • Mis en page en 85 ms
  • 147 lignes de code

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 afficher des listings de code ?
  • 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

script.js · lignes 26–41dans le code complet
// 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

Utilise aussi
Mathématiques
Typographie
Charis SIL, Sora, JetBrains Mono (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

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

Le code de cette étape est la réponse courte ci-dessus. Chaque bloc délimité par ``` dans le Markdown est un bloc 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) : 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 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). 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

script.js · lignes 124–127dans le code complet
  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 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

script.js · lignes 45–54dans le code complet
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). 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

script.js · lignes 58–65dans le code complet
// 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), 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

script.js · lignes 91–94dans le code complet
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). 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

script.js · lignes 69–76dans le code complet
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). 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

Sandbox
// ═══ 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 = `---
Exemple Markdown · 118 lignes · content.en.mdtitle: "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 · core, fonts, viewer : identique dans toutes les recettes · 224 lignes// ─── 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 ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

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.

-    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.

+  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.

-    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

Erreur fréquente

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. Notes marginales →

  • 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

Texte
Texte original, CC BY 4.0
Polices
Charis SIL (SIL OFL 1.1) · Sora (SIL OFL 1.1) · JetBrains Mono (SIL OFL 1.1)
Sandbox