Vai al contenuto principale
Ricetta numero 48

Ricettario · Capitolo 6 · Riquadri e note

Listati di codice e tasti

Una guida alla shell il cui codice delimitato si compone in riquadri scuri colorati per token, con i tasti disegnati come chip a forma di tasto.

In questa pagina

pp. 50–51 · 2–3 di 3

  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 178 × 229 mm
  • Colonna e mezza, Intercolonna di 6 mm
  • Charis SIL 10/14,5
  • JetBrains Mono
  • Sora
  • 3 pagine
  • Livello
  • Postext 1.29.1
  • Impaginato in 85 ms
  • 147 righe di codice

In breve

Tre pagine di una guida per principianti alla riga di comando. Mostra come comporre gli esempi di codice in riquadri scuri colorati e disegnare tasti come Ctrl come piccoli pulsanti nel testo.

Che cosa comporrai

Tre pagine dal capitolo 4 di The Shell, Gently, una guida tascabile immaginaria alla riga di comando, su una pagina di 178 × 229 mm. Il testo è in Charis SIL giustificato in una colonna di 100 mm, con una colonna a margine sul lato esterno per le note. Ogni listato è un riquadro quasi nero in JetBrains Mono che entra in quella colonna, sotto una linguetta con il nome del file o della sessione. Le parole chiave e i comandi digitati si stampano in ambra, le stringhe in verde e i commenti in grigio. Tasti come Ctrl e Tab sono piccoli tasti bordati dentro le righe giustificate, e un promemoria su due colonne con le scorciatoie Ctrl riempie il piede di pagina 50. Il Markdown mantiene normali blocchi di codice delimitati, e il motore li compone così come sono.

Questa ricetta risponde a

  • Come mostro listati di codice?
  • Come creo chip in linea: tasti della tastiera, etichette, banche di parole per esercizi?
  • Come scrivo trattini di dialogo, anni a inizio paragrafo, prezzi e simboli letterali senza che il Markdown li interpreti male?

La risposta breve

script.js · righe 26–41nel codice completo
// 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
};

Ingredienti

Usa anche
Matematica
Tipo
Charis SIL, Sora, JetBrains Mono (SIL OFL 1.1)
Risorse
Nessuna: ogni immagine è disegnata nel codice

Preparazione

#1 · Dai un riquadro ai blocchi delimitati

Il codice di questo passo è la risposta breve qui sopra. Ogni delimitatore ``` nel Markdown è un blocco di codice: Postext compone le sue righe come sono state scritte, una riga di codice per riga di testo, mantenendo i rientri, le righe vuote dello script e l’output su due colonne di du. codeStyle disegna tutti i listati in una volta (code listings): il carattere e la sua interlinea, il fondo quasi nero, la spaziatura interna e lo spazio sopra e sotto. Ciò che segue la lingua nella riga del delimitatore (Terminal, backup.sh) è il titolo del listato, e poiché codeStyle.label è impostato si stampa su una linguetta sul bordo superiore del riquadro invece che in una riga di titolo. codeStyle.inline compone il codice in linea del testo corrente, ls o grep -c, nello stesso carattere a 0,88 em (inline code). Il paragrafo dopo un listato va in :::paragraphs{style="resume"}, così comincia senza rientro, come farebbe dopo un titolo.

#2 · Tieni stretto il testo e lascia che il codice attraversi il margine

script.js · righe 124–127nel codice completo
  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' },

A 10 pt, Charis SIL compone circa 62 caratteri nella colonna di 100 mm. La colonna laterale di questo layout a colonna e mezza accoglie solo elementi flottanti e note, quindi il testo non vi entra mai. span: 'page' in codeStyle estende ogni listato su entrambe le colonne, 143 mm. Dentro la sua spaziatura interna un riquadro contiene 73 caratteri di JetBrains Mono a 8,6 pt; la riga più lunga di queste pagine, il commento che apre backup.sh, ne ha 71.

#3 · Colora il codice per token

script.js · righe 45–54nel codice completo
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,
  };
}

La lingua nel delimitatore (console, bash) sceglie il tokenizzatore incorporato, e codeStyle.tokens dà a ogni tipo di token il suo colore, peso e inclinazione (syntax colouring). In backup.sh, parole chiave come for e done e comandi interni come set ed echo si stampano in grassetto ambra, le stringhe in corsivo verde e i commenti in grigio; programmi come mkdir e rsync restano semplici. Lo stile predefinito compone i commenti in corsivo, quindi italic: false li tiene in tondo. In un blocco console una riga che si apre con un prompt è un token prompt e ogni altra riga è output, così i comandi che digiti si stampano in grassetto ambra e la risposta della shell resta nel grigio chiaro del listato. I tipi che la ricetta non vuole colorati, tra cui numeri, variabili e opzioni, prendono anch’essi quel grigio.

#4 · Componi i tasti come chip

script.js · righe 58–65nel codice completo
// 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) },
];

I tasti sono chip key con un fondo chiaro e un contorno di 0,6 pt. La loro dimensione è data in punti, quindi un tasto misura 7,8 pt nel testo da 10 pt, nella nota a margine da 8,6 pt e nel promemoria da 8,4 pt; em(0.78) ridurrebbe i tasti del promemoria a 6,6 pt. Un chip non si spezza mai tra due righe e non si allarga mai (inline chips), quindi una riga con dei tasti mette tutto il suo gioco negli spazi tra le parole. spacing fa provare all’algoritmo di divisione in righe altri punti di rottura prima che uno spazio superi il 140 % della sua larghezza normale. Con il valore predefinito, 200 %, sei righe di queste pagine si allargano oltre; con l’impostazione, la più larga arriva al 132 %.

#5 · Numera i passi sulla griglia

script.js · righe 91–94nel codice completo
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

I numeri sono Sora 800 nel colore d’accento, e il separatore è un › monospaziato in grigio. Il separatore ha il suo carattere e il suo colore, quindi si disegna come sequenza a parte (ordered lists). I margini dell’elenco sono zero, così i passi proseguono sulla griglia da 14,5 pt del testo attorno. Il valore predefinito di 1,5 em aprirebbe 5,3 mm sopra i passi e spingerebbe il promemoria a pagina 51.

#6 · Fai flottare il promemoria al piede della pagina

script.js · righe 69–76nel codice completo
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 } };

Il promemoria è uno stile di riquadro con il fondo dei listati, Sora per le azioni, e placement: 'bottom', che lo fa flottare al piede di pagina 50 (floated boxes). Lasciato nel flusso, un riquadro a tutta larghezza ha bisogno di spazio per sé e per due righe di testo sotto. Qui il riquadro passerebbe a pagina 51, lasciando 58 mm vuoti sotto i passi, e il capitolo arriverebbe a quattro pagine. Nel Markdown, :::columns{count=2 breaks="8"} apre la colonna destra all’ottavo blocco, il suo titolo. Il bilanciamento da solo taglia per altezza, e quando un’azione va su una seconda riga lascia Commands and history al piede della colonna sinistra. Ogni voce si apre con gli stessi due chip, Ctrl e una lettera, entrambi nel carattere monospaziato, così ogni azione comincia alla stessa distanza dal bordo sinistro della sua colonna.

La ricetta completa

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 = `---
Esempio Markdown · 118 righe · 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: uguale in ogni ricetta · 224 righe// ─── 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 ───────────────────────────────────────────────────────────────────────

Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)

Varianti

#Appendi la linguetta a destra

La linguetta passa all’angolo superiore destro del riquadro, che su un recto sta sopra la colonna a margine.

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

#Numera le righe

Ogni listato riceve numeri di riga in grigio in una fascia dentro il suo riquadro. La fascia occupa circa quattro dei 73 caratteri, quindi il commento che apre backup.sh non entra più: va a capo dopo backup, e disk. prosegue sotto dietro un ». La verifica del Ricettario lo segnala come avviso codeOverflow; accorcia il commento per tenere lo script nelle sue 13 righe. Un singolo listato prende i numeri con {lineNumbers} nella riga del suo delimitatore.

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

#Dai ai commenti il colore delle stringhe

I commenti prendono il verde delle stringhe, in corsivo.

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

Errori frequenti

Errore frequente

Un riquadro laterale parte all’altezza del blocco che segue il suo delimitatore

In postext 1.4.1 un riquadro span: 'side' si colloca nella colonna laterale all’altezza a cui è arrivato il testo nel punto del suo delimitatore, sulla riga successiva della griglia e sotto qualunque riquadro già presente. Metti il delimitatore di una glossa subito prima del paragrafo che spiega: messa dopo, la glossa comincia accanto al paragrafo seguente. Un riquadro che sforerebbe il piede della colonna sale finché il suo piede non coincide con il piede della colonna, per quanto lo permette il riquadro sopra di esso; uno che ancora non ci sta aspetta la colonna laterale della pagina successiva. Note a margine →

  • Tieni ogni riga di codice entro i 73 caratteri. Una più lunga va a capo all’ultimo spazio o segno di punteggiatura che ci sta e prosegue sotto dietro un », e il motore segnala un avviso codeOverflow; overflow: 'shrink' compone invece tutto il listato più piccolo, e 'clip' taglia la riga al bordo del riquadro.
  • Una tabulazione in un listato avanza al multiplo di quattro colonne successivo (codeStyle.tabSize). Uno script rientrato con tabulazioni che il tuo editor mostra larghe otto colonne qui risulta più stretto.
  • Con codeStyle.inline impostato, il codice in linea non si spezza mai tra due righe, come un chip. Un percorso lungo nel testo corrente, come ~/Pictures/2025/IMG_0977.MOV, passa intero alla riga successiva e può lasciarne dietro una troppo spaziata.

Crediti

Testo
Testo originale, CC BY 4.0
Font
Charis SIL (SIL OFL 1.1) · Sora (SIL OFL 1.1) · JetBrains Mono (SIL OFL 1.1)
Sandbox