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
// 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
- Fonctionnalités
- Listings de codePastilles dans le texteEncadrésOnglets numérotés d’encadréColonnes dans un encadré ou dans le texteEncadrés sur toute la largeurEncadrés flottantsNotes marginalesColonne et demieColonne marginale pour les flottantsGras, italique et leurs couleursListes numérotéesOuvertures composéesAttributs de titreTitres courants et foliosTitres courants selon le rôle de la pageMarges en miroirPalette de couleurs sémantiqueStyles de paragrapheCoupure optimale des lignes (Knuth–Plass)
- 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
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
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
// 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
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
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
// ═══ 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.md
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 · 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 italicErreurs 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 avertissementcodeOverflow;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.inlineré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
- 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)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


