成品一览
The Shell, Gently第4章的三页。这是一本虚构的命令行袖珍指南,页面尺寸178 × 229 mm。正文用Charis SIL在100 mm宽的栏里两端对齐排版,外侧另有一栏放注释。每段代码清单是一个近黑色的JetBrains Mono框,伸进那一栏,顶上有个标签写着文件名或会话名。关键字和输入的命令印成琥珀色,字符串绿色,注释灰色。Ctrl、Tab等按键是两端对齐行内的小号描边键帽,第50页下部是一张两栏的Ctrl快捷键速查表。Markdown里保留普通的围栏代码块,构建前由一个短函数把每个代码块转成框。
这道食谱解答
- 怎样展示代码清单?
- 怎样做行内标签:键盘按键、标签、练习用的词库?
- 怎样写对话破折号、段首的年份、价格和字面符号,而不被Markdown误读?
简短回答
// Postext sets no fenced code, so the Markdown is rewritten before the build:
// ```bash backup.sh … ``` → :::callout{type="listing" label="backup.sh"} … :::
// Characters Markdown would read as emphasis, a superscript or subscript, code or maths get
// a backslash (gotcha: dollar-math). The parser drops a backslash only before those, so any
// other backslash in the code prints as typed. ']\u2060(' keeps '[a](b)' from becoming a link.
const escape = (text) => text.replace(/[*_^~`$]/g, '\\$&').replace(/\]\(/g, ']\u2060(');
function codeLine(line, lang) {
// A word joiner (U+2060) opens every line, so a leading '#', '-', '1.' or '>' stays text
// (gotcha: digit-period-list). Parsing trims leading spaces, no-break ones included;
// the word joiner in front keeps them.
const indent = line.match(/^ */)[0].length; // indent listings with spaces, not tabs
const body = (lang === 'console' ? session : paint)(line.slice(indent));
const runs = body.replace(/ {2,}/g, (run) => NBSP.repeat(run.length)); // output columns
return `\u2060${NBSP.repeat(indent)}${runs}`;
}
// The label stops at a double quote, which would close the attribute.
const listings = (markdown) => markdown.replace(/^```(\w*) *([^"\n]*).*\n([\s\S]*?)^```$/gm,
(_, lang, label, code) => [`:::callout{type="listing" label="${label || lang}"}`,
// One paragraph per line; a blank line keeps the word joiner alone.
...code.replace(/\n$/, '').split('\n').map((line) => codeLine(line, lang)), ':::',
].join('\n\n'));
// The text after a listing goes in :::paragraphs{style="resume"}: flush, as after a heading.
const resume = { id: 'resume', firstLineIndent: ZERO };
const listing = {
id: 'listing', 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' },
body: { fontFamily: MONO, fontSize: pt(8.6), lineHeight: pt(12.4), textAlign: 'left',
color: col('code'), boldColor: col('amber'), italicColor: col('phosphor'), // paint()
// One paragraph per line of code: no space between them, even if bodyText adds some.
paragraphSpacing: false, firstLineIndent: ZERO },
};
用料
做法
#1 · 构建前把每个围栏代码块转成框
这一步的代码见上文的简短回答。Postext 1.4.1把围栏代码块当普通Markdown读:各行连成一个段落,每对美元符号变成公式,下划线开启斜体,注释# Copy each folder…还会变成章标题。listings()把每个围栏代码块改写成一个listing框,每行一个段落,并在Markdown会解析的每个字符前加反斜杠。每行开头的字连接符(U+2060)保住了缩进。没有它,解析器会删掉不换行空格,backup.sh的循环体会丢掉缩进,脚本里的两个空行也会消失。围栏行上语言名之后的内容(backup.sh、Terminal)印在框的标签页上。清单之后的段落放在:::paragraphs{style="resume"}里,因此首行不缩进,就像跟在标题后面一样。
#2 · 正文窄排,让代码跨进页边
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在100 mm的栏里每行约62个字符。这种一栏半版面的侧栏只放浮动体和注释,正文永远不会流进去。清单样式的span: 'page'(见简短回答)让每段清单横跨两栏,宽143 mm。扣除内边距后,一个框能容纳73个8.6 pt的JetBrains Mono字符;这几页最长的一行,即backup.sh的第二行,有71个。
#3 · 用粗体和斜体给代码着色
const KEYWORDS = 'if|then|else|elif|fi|for|in|do|done|while|until|case|esac' // reserved words
+ '|set|echo|cd|export|local|read'; // builtins; programs such as mkdir and rsync stay plain
const TOKEN = new RegExp(`("(?:\\\\.|[^"\\\\])*"|'[^']*')` // a quoted string
+ `|((?:^|(?<=\\s))#.*$)|\\b(${KEYWORDS})\\b`, 'g'); // a comment, a keyword
function paint(line) {
let out = '';
let last = 0;
for (const { 0: token, 1: string, 2: comment, index } of line.matchAll(TOKEN)) {
out += escape(line.slice(last, index));
if (string) out += `*${escape(string)}*`;
else if (comment) out += `:chip[${chipText(comment)}]{style="rem"}`;
else out += `**${token}**`;
last = index + token.length;
}
return out + escape(line.slice(last));
}
// In a session, what you type after the prompt is bold; the shell's answer stays plain.
const session = (line) => line.startsWith('$ ') ? `\\$ **${escape(line.slice(2))}**` : escape(line);
Postext没有语法高亮,但框会用自己的boldColor印粗体片段,用italicColor印斜体片段,所以paint()把关键字设为粗体(琥珀色),把带引号的字符串设为斜体(绿色)。注释的第三种颜色来自rem,这是一个没有填充、描边、内边距和间隙的标签样式,用等宽字体把文字印成灰色。KEYWORDS列出Shell的保留字和几个内建命令(set、echo、cd);mkdir、rsync这类程序保持原样。在console围栏里,session()把提示符之后输入的内容设为粗体,Shell的输出保持普通字重。
#4 · 把按键和行内代码排成行内标签
// 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 %). Inside a chip
// maths stays literal, so '$' needs no backslash there, but ']' does.
const spacing = { maxWordSpacing: 1.4 }; // spread into bodyText
const chipText = (text) => text.replace(/[*_^~`]/g, '\\$&').replace(/]/g, '\\]');
const inlineCode = (markdown) => markdown.replace(/(?<!\\)`([^`\n]+)`/g,
(_, code) => `:chip[${chipText(code)}]{style="code"}`);
const bare = { backgroundEnabled: false, borderWidth: ZERO, paddingX: ZERO, gap: ZERO };
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) },
{ id: 'code', fontFamily: MONO, fontSize: em(0.88), ...bare }, // `grep` in running text
{ id: 'rem', fontFamily: MONO, color: col('slate'), ...bare }, // a comment in a listing
];
Postext会去掉反引号,用正文字体排行内代码(行内格式),所以inlineCode()把每个片段转成code标签:0.88 em的等宽字体,不带框。按键是key标签,浅色填充,0.6 pt描边。其大小以pt给出,所以无论在10 pt正文、8.6 pt页边注还是8.4 pt速查表里,按键都是7.8 pt;若用em(0.78),速查表里的按键会缩到6.6 pt。行内标签永远不跨行断开,也不会拉伸(行内标签),所以带按键的行把全部多余空间都放进词间空格。spacing让断行程序在空格超过正常宽度140 %之前先尝试其他断点。按默认的200 %,这几页有六行的空格拉得比这更宽;加上这项设置后,最宽的是132 %。
#5 · 在网格上给步骤编号
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
编号用强调色的Sora 800,分隔符是灰色的等宽›。分隔符有自己的字体和颜色,所以作为单独的片段绘制(有序列表)。列表外边距为零,所以步骤延续周围正文14.5 pt的网格。默认的1.5 em会在步骤上方空出5.3 mm,把速查表推到第51页。
#6 · 让速查表浮动到页面下部
const sheet = { ...listing, id: 'sheet', label: undefined, placement: 'bottom',
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: { ...listing.body, fontFamily: DISPLAY, fontSize: pt(8.4), lineHeight: pt(13) } };
速查表沿用清单样式,操作说明用Sora,placement: 'bottom'让它浮动到第50页下部(浮动框)。留在文本流里的话,一个通栏的框需要容纳自身和下面两行文字的空间。这里框会移到第51页,步骤下面空出58 mm,这一章也会排成四页。在Markdown里,:::columns{count=2 breaks="8"}在第八个块,也就是其标题处开启右栏。只靠齐底会按高度切分,一旦某条操作折成两行,Commands and history就会落在左栏底部。每一条都以同样的两个标签开头,Ctrl和一个字母,都用等宽字体,所以每条操作都从距所在栏左边缘相同的位置开始。
完整食谱
// ═══ Postext Cookbook · Nº 048 · Code listings and keycaps without code blocks ═══ // 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.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache } 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) // Design elements read the hex, not the palette id (gotcha: palette-skips-designs). 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 [NBSP, ZERO] = ['\u00a0', pt(0)]; // #region answer: a fenced block becomes a dark box with one escaped paragraph per line // Postext sets no fenced code, so the Markdown is rewritten before the build: // ```bash backup.sh … ``` → :::callout{type="listing" label="backup.sh"} … ::: // Characters Markdown would read as emphasis, a superscript or subscript, code or maths get // a backslash (gotcha: dollar-math). The parser drops a backslash only before those, so any // other backslash in the code prints as typed. ']\u2060(' keeps '[a](b)' from becoming a link. const escape = (text) => text.replace(/[*_^~`$]/g, '\\$&').replace(/\]\(/g, ']\u2060('); function codeLine(line, lang) { // A word joiner (U+2060) opens every line, so a leading '#', '-', '1.' or '>' stays text // (gotcha: digit-period-list). Parsing trims leading spaces, no-break ones included; // the word joiner in front keeps them. const indent = line.match(/^ */)[0].length; // indent listings with spaces, not tabs const body = (lang === 'console' ? session : paint)(line.slice(indent)); const runs = body.replace(/ {2,}/g, (run) => NBSP.repeat(run.length)); // output columns return `\u2060${NBSP.repeat(indent)}${runs}`; } // The label stops at a double quote, which would close the attribute. const listings = (markdown) => markdown.replace(/^```(\w*) *([^"\n]*).*\n([\s\S]*?)^```$/gm, (_, lang, label, code) => [`:::callout{type="listing" label="${label || lang}"}`, // One paragraph per line; a blank line keeps the word joiner alone. ...code.replace(/\n$/, '').split('\n').map((line) => codeLine(line, lang)), ':::', ].join('\n\n')); // The text after a listing goes in :::paragraphs{style="resume"}: flush, as after a heading. const resume = { id: 'resume', firstLineIndent: ZERO }; const listing = { id: 'listing', 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' }, body: { fontFamily: MONO, fontSize: pt(8.6), lineHeight: pt(12.4), textAlign: 'left', color: col('code'), boldColor: col('amber'), italicColor: col('phosphor'), // paint() // One paragraph per line of code: no space between them, even if bodyText adds some. paragraphSpacing: false, firstLineIndent: ZERO }, }; // #endregion // #region paint: keywords bold, strings italic, comments a chip with no box const KEYWORDS = 'if|then|else|elif|fi|for|in|do|done|while|until|case|esac' // reserved words + '|set|echo|cd|export|local|read'; // builtins; programs such as mkdir and rsync stay plain const TOKEN = new RegExp(`("(?:\\\\.|[^"\\\\])*"|'[^']*')` // a quoted string + `|((?:^|(?<=\\s))#.*$)|\\b(${KEYWORDS})\\b`, 'g'); // a comment, a keyword function paint(line) { let out = ''; let last = 0; for (const { 0: token, 1: string, 2: comment, index } of line.matchAll(TOKEN)) { out += escape(line.slice(last, index)); if (string) out += `*${escape(string)}*`; else if (comment) out += `:chip[${chipText(comment)}]{style="rem"}`; else out += `**${token}**`; last = index + token.length; } return out + escape(line.slice(last)); } // In a session, what you type after the prompt is bold; the shell's answer stays plain. const session = (line) => line.startsWith('$ ') ? `\\$ **${escape(line.slice(2))}**` : escape(line); // #endregion // #region keycaps: keys, and inline code in the mono face, 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 %). Inside a chip // maths stays literal, so '$' needs no backslash there, but ']' does. const spacing = { maxWordSpacing: 1.4 }; // spread into bodyText const chipText = (text) => text.replace(/[*_^~`]/g, '\\$&').replace(/]/g, '\\]'); const inlineCode = (markdown) => markdown.replace(/(?<!\\)`([^`\n]+)`/g, (_, code) => `:chip[${chipText(code)}]{style="code"}`); const bare = { backgroundEnabled: false, borderWidth: ZERO, paddingX: ZERO, gap: ZERO }; 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) }, { id: 'code', fontFamily: MONO, fontSize: em(0.88), ...bare }, // `grep` in running text { id: 'rem', fontFamily: MONO, color: col('slate'), ...bare }, // a comment in a listing ]; // #endregion // #region sheet: a two-column cheat sheet floated to the foot of its page const sheet = { ...listing, id: 'sheet', label: undefined, placement: 'bottom', 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: { ...listing.body, fontFamily: DISPLAY, fontSize: pt(8.4), lineHeight: pt(13) } }; // #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', overflow: 'wrap' }); // design text is centred and cut with '…' by default 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) } }), // Design lineHeights are multiples (gotcha: design-lineheight-multiple). 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 = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) colorPalette, chipStyles, orderedLists, paragraphStyles: [resume], calloutStyles: [listing, sheet, aside, colophon], // #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, maxRuntTracking: 0, // tracking it cannot paint (gotcha: runt-tracking-unpainted) }, headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 800, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { 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样例 · 118行 · 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 source = inlineCode(listings(markdown)); // fences first: their backticks are escaped 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 loadFonts(FONTS, markdown); const build = () => buildDocument({ markdown: source, continuation }, config()); const doc = await buildWithFonts(build, markdown); showPages(doc, { title: t({ en: 'The Shell, Gently', es: 'La terminal, con calma' }) });工具包 · core, fonts, viewer:每道食谱都相同 · 235行
// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
常见问题
易错点
不换行空格仍然会断行
在postext 1.4.1中,断行器把U+00A0当作普通空格,所以0.08 %、2.006 s或Section 2可能被拆到两行。把两部分连写(0.08%),或者改写句子。 转义与字面字符 →
易错点
侧栏框与其围栏之后的块齐平开始
在postext 1.4.1中,span: 'side'的框在侧栏中的位置,是正文排到其围栏处时的高度,落在下一个网格行上,并排在已在那里的框之下。把旁注围在它所解释的段落之前:围在段落之后,旁注就会从下一段旁边开始。会超出栏底的框会向上滑,在上方框允许的范围内,直到底边落在栏底;仍然放不下的,就等到下一页的侧栏。 旁注 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
消除孤字时收紧的字距可能根本不绘制
在postext 1.4.1中,段落以孤字结尾时,排版会把它排短一行:先收紧词间距,再用最多maxRuntTracking个千分之一em的负字距。Canvas和PDF渲染器只绘制大于零的字距,所以收紧了字距的段落印出来时并没有收紧:两端对齐的行从词间空格中扣掉了这部分差额,显得拥挤,末行还可能超出行长,在栏边被裁掉。设置bodyText.maxRuntTracking: 0,保留词间距的修正,再改写重新出现的孤字。 段末孤行、段首孤行与孤字 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
- 清单用空格缩进。
codeLine()把行首空格和连续空格转成不换行空格,而制表符只印成一个普通空格。 - 每行代码不超过73个字符。更长的行会在空格处折行,而注释是行内标签,永远不断开:它会掉到单独的一行,并超出框的边缘。
codeLine()的转义在正文里同样适用。以年份开头的段落,如1998. The lab opened,除非前面先放一个字连接符,否则会变成列表的第1998项;\$40能让价格不被当成数学公式。以长破折号开头的对白不需要转义;连字符加空格则会开启一个列表。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- Charis SIL (SIL OFL 1.1) · Sora (SIL OFL 1.1) · JetBrains Mono (SIL OFL 1.1)


