Kurz gesagt
Drei Seiten aus einem Einsteigerleitfaden für die Kommandozeile. Sie zeigen, wie Codebeispiele in dunklen farbigen Kästen stehen und Tasten wie Ctrl als kleine Knöpfe im Text erscheinen.
Was Sie setzen
Drei Seiten aus Kapitel 4 von The Shell, Gently, einem erfundenen Taschenleitfaden für die Kommandozeile, auf einer Seite von 178 × 229 mm. Der Text steht im Blocksatz aus der Charis SIL in einer 100 mm breiten Spalte, mit einer Randspalte für Marginalien an der Außenseite. Jedes Listing ist ein fast schwarzer Kasten aus JetBrains Mono, der in diese Spalte hineinreicht, unter einem Tab mit dem Namen der Datei oder der Sitzung. Schlüsselwörter und getippte Befehle erscheinen in Bernstein, Zeichenketten in Grün und Kommentare in Grau. Tasten wie Ctrl und Tab sind kleine umrandete Tastenkappen in den Blocksatzzeilen, und ein zweispaltiger Spickzettel mit Ctrl-Kürzeln füllt den Fuß von Seite 50. Das Markdown behält gewöhnliche Codeblöcke, und die Engine setzt sie, wie sie sind.
Dieses Rezept beantwortet
- Wie zeige ich Code-Listings?
- Wie erstelle ich Inline-Chips: Tastaturtasten, Schlagwörter, Wortspeicher für Übungen?
- Wie schreibe ich Dialogstriche, Jahreszahlen am Absatzanfang, Preise und wörtliche Symbole, ohne dass Markdown sie falsch liest?
Die kurze Antwort
// 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
};
Zutaten
- Funktionen
- Code-ListingsInline-ChipsKästenNummerierte KastenreiterSpalten in einem Kasten oder im TextKästen über die SeiteGleitende KästenMarginalienAnderthalb SpaltenRandspalte für GleitobjekteFett, kursiv und ihre FarbenNummerierte ListenGestaltete AuftakteÜberschriftenattributeKolumnentitel und SeitenzahlenKopfzeilen nach SeitenrolleGespiegelte RänderSemantische FarbpaletteAbsatzformateOptimaler Zeilenumbruch (Knuth-Plass)
- Nutzt außerdem
- Mathematik
- Schrift
- Charis SIL, Sora, JetBrains Mono (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Den Codeblöcken einen Kasten geben
Der Code für diesen Schritt steht oben in der kurzen Antwort. Jeder Block zwischen zwei Zeilen ``` im Markdown ist ein Codeblock: Postext setzt seine Zeilen so, wie sie getippt wurden, eine Codezeile pro Satzzeile, mit den Einzügen, den Leerzeilen des Skripts und der zweispaltigen Ausgabe von du. codeStyle gestaltet alle Listings auf einmal (Codelistings): Schrift und Zeilenabstand, die fast schwarze Fläche, den Innenabstand und den Raum darüber und darunter. Was in der öffnenden Zeile des Codeblocks auf die Sprache folgt (Terminal, backup.sh), ist der Titel des Listings, und weil codeStyle.label gesetzt ist, erscheint er auf einem Tab an der Oberkante des Kastens statt in einer Titelzeile. codeStyle.inline setzt den Inline-Code des Fließtexts, ls oder grep -c, in derselben Schrift in 0,88 em (Inline-Code). Der Absatz nach einem Listing steht in :::paragraphs{style="resume"}, sodass er stumpf beginnt, wie nach einer Überschrift.
#2 · Den Text schmal halten und den Code über den Rand laufen lassen
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' },
In 10 pt setzt die Charis SIL etwa 62 Zeichen in die 100 mm breite Spalte. Die Seitenspalte dieses anderthalbspaltigen Layouts nimmt nur Gleitobjekte und Marginalien auf, sodass der Text nie in sie hineinläuft. span: 'page' in codeStyle zieht jedes Listing über beide Spalten, 143 mm. Innerhalb seines Innenabstands fasst ein Kasten 73 Zeichen JetBrains Mono in 8,6 pt; die längste Zeile auf diesen Seiten, der Kommentar zu Beginn von backup.sh, hat 71.
#3 · Den Code nach Token einfärben
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,
};
}
Die Sprache in der öffnenden Zeile (console, bash) wählt den eingebauten Tokenizer, und codeStyle.tokens gibt jeder Token-Art Farbe, Schriftstärke und Neigung (Syntaxhervorhebung). In backup.sh erscheinen Schlüsselwörter wie for und done und eingebaute Befehle wie set und echo in fettem Bernstein, Zeichenketten grün kursiv und Kommentare grau; Programme wie mkdir und rsync bleiben schlicht. Der Standardstil setzt Kommentare kursiv, deshalb hält italic: false sie aufrecht. In einem Codeblock console ist eine Zeile, die mit einem Prompt beginnt, ein Token prompt und jede andere Zeile output, sodass die getippten Befehle in fettem Bernstein erscheinen und die Antwort der Shell im hellen Grau des Listings bleibt. Die Arten, die das Rezept nicht einfärben will, darunter Zahlen, Variablen und Optionen, nehmen ebenfalls dieses Grau an.
#4 · Tasten als Chips setzen
// 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) },
];
Tasten sind Chips key mit heller Fläche und einer Kontur von 0,6 pt. Ihre Größe ist in Punkt angegeben, sodass eine Taste im Text zu 10 pt, in der Marginalie zu 8,6 pt und im Spickzettel zu 8,4 pt gleich 7,8 pt misst; em(0.78) würde die Tasten des Spickzettels auf 6,6 pt schrumpfen lassen. Ein Chip bricht nie über Zeilen um und dehnt sich nie (Inline-Chips), sodass eine Zeile mit Tasten ihren ganzen Spielraum in die Wortabstände legt. spacing lässt den Zeilenumbruch andere Umbruchstellen versuchen, bevor ein Wortabstand über 140 % seiner normalen Breite wächst. Beim Standardwert von 200 % dehnen sich sechs Zeilen dieser Seiten weiter; mit der Einstellung erreicht die breiteste 132 %.
#5 · Die Schritte im Raster nummerieren
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
Die Nummern stehen in der Sora 800 in der Akzentfarbe, der Trenner ist ein dicktengleiches › in Grau. Der Trenner hat eigene Schrift und Farbe und wird deshalb als eigener Lauf gezeichnet (Nummerierte Listen). Die Listenränder sind null, sodass die Schritte im 14,5-pt-Raster des umgebenden Texts weiterlaufen. Der Standardwert von 1,5 em würde über den Schritten 5,3 mm öffnen und den Spickzettel auf Seite 51 schieben.
#6 · Den Spickzettel an den Fuß der Seite gleiten lassen
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 } };
Der Spickzettel ist ein Kastenstil mit der Fläche der Listings, der Sora für die Aktionen und placement: 'bottom', das ihn an den Fuß von Seite 50 gleiten lässt (Gleitende Kästen). Bliebe er im Textfluss, bräuchte ein Kasten über die ganze Seitenbreite Platz für sich und zwei Textzeilen darunter. Hier würde der Kasten auf Seite 51 rücken, unter den Schritten 58 mm leer lassen, und das Kapitel liefe auf vier Seiten. Im Markdown eröffnet :::columns{count=2 breaks="8"} die rechte Spalte beim achten Block, ihrer Überschrift. Der Ausgleich allein bricht nach Höhe um, und wenn eine Aktion auf eine zweite Zeile läuft, bleibt Commands and history am Fuß der linken Spalte stehen. Jeder Eintrag beginnt mit denselben zwei Chips, Ctrl und einem Buchstaben, beide in der dicktengleichen Schrift, sodass jede Aktion im selben Abstand vom linken Rand ihrer Spalte beginnt.
Das ganze Rezept
// ═══ 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 = `---Markdown-Beispiel · 118 Zeilen · 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: in jedem Rezept gleich · 224 Zeilen
// ─── 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 ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Den Tab rechts aufhängen
Der Tab rückt an die obere rechte Ecke des Kastens, die auf einer rechten Seite über der Randspalte steht.
- position: 'top-left' },
+ position: 'top-right' },#Die Zeilen nummerieren
Jedes Listing bekommt graue Zeilennummern in einem Steg innerhalb seines Kastens. Der Steg nimmt etwa vier der 73 Zeichen ein, sodass der Kommentar zu Beginn von backup.sh nicht mehr passt: Er bricht nach backup um, und disk. läuft hinter einem » darunter weiter. Die Prüfung des Rezeptbuchs meldet das als Warnung codeOverflow; kürzen Sie den Kommentar, damit das Skript bei seinen 13 Zeilen bleibt. Ein einzelnes Listing bekommt Nummern mit {lineNumbers} in seiner öffnenden Zeile.
+ lineNumbers: true, lineNumberColor: col('slate'),
tokens: tokens(), // the colours, below#Kommentaren die Farbe der Zeichenketten geben
Die Kommentare nehmen das Grün der Zeichenketten an, kursiv.
- comment: { color: col('slate'), italic: false }, // upright: the default comment is italic
+ comment: { color: col('phosphor'), italic: true }, // the strings' green italicHäufige Fehler
Häufiger Fehler
Ein Seitenkasten beginnt auf Höhe des Blocks nach seiner Einfassung
In postext 1.4.1 steht ein Kasten mit span: 'side' in der Seitenspalte auf der Höhe, die der Text an seiner Einfassung erreicht hat, auf der nächsten Rasterlinie und unter einem bereits vorhandenen Kasten. Setzen Sie eine Glosse direkt vor den Absatz, den sie erläutert: Steht sie danach, beginnt sie neben dem nächsten Absatz. Ein Kasten, der über den Fuß der Spalte hinauslaufen würde, rutscht nach oben, bis sein Fuß auf dem Fuß der Spalte sitzt, soweit der Kasten darüber es zulässt; passt er dann immer noch nicht, wartet er auf die Seitenspalte der nächsten Seite. Marginalien →
- Halten Sie jede Codezeile bei höchstens 73 Zeichen. Eine längere bricht an ihrem letzten passenden Leerzeichen oder Satzzeichen um und läuft hinter einem
»darunter weiter, und die Engine meldet eine WarnungcodeOverflow;overflow: 'shrink'setzt stattdessen das ganze Listing kleiner, und'clip'schneidet die Zeile an der Kante des Kastens ab. - Ein Tabulator in einem Listing springt zum nächsten Vielfachen von vier Spalten (
codeStyle.tabSize). Ein mit Tabulatoren eingerücktes Skript, das Ihr Editor acht Spalten breit zeigt, fällt hier schmaler aus. - Ist
codeStyle.inlinegesetzt, bricht Inline-Code wie ein Chip nie über Zeilen um. Ein langer Pfad im Fließtext wie~/Pictures/2025/IMG_0977.MOVrückt ganz in die nächste Zeile und kann davor eine zu lockere Zeile hinterlassen.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Eigener Text, CC BY 4.0
- Schriften
- Charis SIL (SIL OFL 1.1) · Sora (SIL OFL 1.1) · JetBrains Mono (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


