# コードブロックを使わずに組むコードリストとキーキャップ

> シェルの入門書。フェンス付きコードを組版前に濃色のリスト囲みへ書き換え、太字とイタリックをシンタックスの色に、キーをキーキャップのチップにします。

- HTML版: https://postext.dev/ja/cookbook/code-listings-and-keycaps
- レシピ No. 048 · 囲みと注 · 難易度 2 (中級) · 出力: Canvas
- ジャンル: マニュアル・ガイド・リファレンス
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.4.1 ／テスト日 2026-09-26
- ページ: [49](https://postext.dev/cookbook/code-listings-and-keycaps/en/p01.webp?v=541c0f54), [50](https://postext.dev/cookbook/code-listings-and-keycaps/en/p02.webp?v=541c0f54), [51](https://postext.dev/cookbook/code-listings-and-keycaps/en/p03.webp?v=541c0f54)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=code-listings-and-keycaps&lang=en (.postext: https://postext.dev/cookbook/code-listings-and-keycaps/en/code-listings-and-keycaps.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/code-listings-and-keycaps.md), [es](https://postext.dev/es/cookbook/code-listings-and-keycaps.md), [ca](https://postext.dev/ca/cookbook/code-listings-and-keycaps.md), [zh](https://postext.dev/zh/cookbook/code-listings-and-keycaps.md), [ar](https://postext.dev/ar/cookbook/code-listings-and-keycaps.md)

## かんたんな説明

コマンドライン入門書の3ページ。コードの例を濃い色の箱に変え、Ctrlのようなキーを文中の小さなボタンとして描く方法を示します。

## できあがり

『The Shell, Gently』という架空のコマンドライン小型入門書から、第4章の3ページを178 × 229 mmの判型で組みます。本文はCharis SILの両端そろえで、100 mmの段に組みます。小口側には傍注用の余白段を設けます。コードリストはどれもJetBrains Monoを組んだ黒に近い囲みで、この余白段まで張り出し、ファイル名かセッション名を記したタブを上に付けます。キーワードと入力コマンドは琥珀色、文字列は緑、コメントはグレーで印刷されます。CtrlやTabなどのキーは両端そろえの行の中に輪郭線付きの小さなキーキャップとして入り、50ページの下端はCtrlショートカットの2段組みチートシートが占めます。Markdownには普通のフェンス付きコードブロックを残し、短い関数がそれぞれを組版前に囲みへ変換します。

**このレシピが答える問い:**

- フェンス付きコードブロックが使えないとき、コードリストやキーボードショートカットはどう見せますか？
- キーボードのキー、タグ、練習問題の語群などのインラインのチップを作るには？
- 会話のダッシュ、段落頭の年号、価格、記号そのものを、Markdownに誤読させずに書くには？

## 手短な答え

````js
// script.js, 行 24–58
// 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 },
};
````

## 材料

**学べること**

- [囲み](https://postext.dev/ja/docs/configuration.md#囲みスタイル): 注記、ヒント、警告に使う名前付きの囲みのスタイルです。背景、枠線、角の丸み、ストライプ、タイトル、そして独自の本文とリストの文字組みを持ちます。
- [エスケープとそのままの文字](https://postext.dev/ja/docs/document-format.md#記述の決まりごと): バックスラッシュによるエスケープと単語結合子を使い、ドル記号、アスタリスク、会話のダッシュ、段落頭の年号がマークアップとして読まれないようにします。
- [インラインのチップ](https://postext.dev/ja/docs/configuration.md#チップスタイル): 語を囲む角の丸い枠です。1つの単位として行を移り、伸縮しません。キーキャップ、タグ、語群、音節などに使います。

**ほかに使うもの**

- [番号付きの囲みのタブ](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [囲みの中の段組み](https://postext.dev/ja/docs/document-format.md#columns)
- [段抜きの囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [フロートする囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [欄外の注](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [1段半組み](https://postext.dev/ja/docs/configuration.md#レイアウトの種類)
- [フロート用の余白段](https://postext.dev/ja/docs/configuration.md#レイアウト)
- [太字・イタリックとその色](https://postext.dev/ja/docs/configuration.md#本文)
- [番号付きリスト](https://postext.dev/ja/docs/configuration.md#番号付きリスト)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [見開きの余白](https://postext.dev/ja/docs/configuration.md#見開きの余白)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [最適な改行（Knuth–Plass）](https://postext.dev/ja/docs/justification.md#knuth-plass段落全体を見渡す)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`chipStyles`](https://postext.dev/ja/docs/configuration.md#チップスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`orderedLists`](https://postext.dev/ja/docs/configuration.md#番号付きリスト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

- Charis SIL (OFL-1.1), Sora (OFL-1.1), JetBrains Mono (OFL-1.1)

## 作り方

### 1 · 組版前に各フェンスを囲みに変える

この手順のコードは上の[手短な答え](#手短な答え)にあります。Postext 1.4.1はフェンス付きブロックを普通のMarkdownとして読みます。行はつながって1つの段落になり、ドル記号の対は数式になり、アンダースコアはイタリックを始め、コメント`# Copy each folder…`は章見出しになってしまいます。`listings()`はすべてのフェンスを`listing`囲みに書き換えます。1行を1段落とし、Markdownが記号として読んでしまう文字の前にはバックスラッシュを置きます。各行の頭に置くワードジョイナー（U+2060）がインデントを守ります。これがないとパーサーがノーブレークスペースを削り、`backup.sh`のループ本体のインデントが消え、スクリプトの2つの空行もなくなります。フェンス行で言語名の後に書いたもの（`backup.sh`、`Terminal`）は、囲みの[ラベルタブ](/ja/docs/configuration#囲みスタイル)に印刷されます。リストの後の段落は`:::paragraphs{style="resume"}`に入れるので、見出しの後と同じく字下げなしで始まります。

### 2 · 本文は狭く、コードは余白まで

```js
// script.js, 行 154–157
  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字入ります。この[1段半組み](/ja/docs/configuration#レイアウトの種類)のサイド段はフロートと傍注だけを受け持つので、本文がそこへ流れ込むことはありません。リストのスタイルの`span: 'page'`（手短な答えにあります）は、すべてのリストを両方の段にまたがる143 mmに広げます。パディングの内側には8.6 ptのJetBrains Monoが73字入ります。これらのページで最も長い行、`backup.sh`の2行目は71字です。

### 3 · 太字とイタリックでコードに色を付ける

```js
// script.js, 行 62–79
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()`はキーワードを太字（琥珀色）に、引用符で囲まれた文字列をイタリック（緑）にします。コメントには3つ目の色を`rem`で与えます。これは塗り、輪郭線、パディング、間隔のないチップスタイルで、文字を等幅書体のグレーで印刷します。`KEYWORDS`にはシェルの予約語といくつかの組み込みコマンド（`set`、`echo`、`cd`）を並べ、`mkdir`や`rsync`のようなプログラムは装飾しません。`console`フェンスでは、`session()`がプロンプトの後に入力する部分を太字にし、シェルの応答はそのままにします。

### 4 · キーとインラインコードをチップにする

```js
// script.js, 行 83–97
// 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はバッククォートを取り除き、インラインコードを本文書体で組みます（[インライン書式](/ja/docs/document-format#インライン書式)）。そこで`inlineCode()`は各スパンを`code`チップにします。0.88 emの等幅書体で、枠は付けません。キーは`key`チップで、淡い塗りと0.6 ptの輪郭線を持ちます。大きさをポイントで指定しているので、キーは10 ptの本文でも、8.6 ptの傍注でも、8.4 ptのチートシートでも7.8 ptになります。`em(0.78)`で指定すると、チートシートのキーは6.6 ptに縮みます。チップは行をまたいで分割されず、伸縮もしません（[インラインのチップ](/ja/docs/document-format#インラインのチップ)）。そのためキーを含む行では、余りがすべて語間に回ります。`spacing`を指定すると、改行処理はスペースが通常幅の140 %を超えて広がる前に別の改行位置を試します。既定の200 %では、これらのページの6行がそれ以上に広がります。この設定を入れると、最も広い行でも132 %に収まります。

### 5 · 手順の番号をグリッドに乗せる

```js
// script.js, 行 120–123
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、区切りはグレーの等幅の`›`です。区切りは独自の書体と色を持つので、別のランとして描かれます（[番号付きリスト](/ja/docs/configuration#番号付きリスト)）。リストの余白はゼロなので、手順は周囲の本文と同じ14.5 ptのグリッドに乗り続けます。既定の1.5 emでは手順の上に5.3 mmの空きができ、チートシートが51ページへ押し出されます。

### 6 · チートシートをページの下端にフロートさせる

```js
// script.js, 行 101–105
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ページの下端にフロートします（[フロートする囲み](/ja/docs/configuration#calloutコンテナー)）。流れの中に置いたままだと、ページ幅の囲みは自身の高さに加えて、その下に本文2行分の余地を必要とします。ここでは囲みが51ページに移り、手順の下に58 mmの空白が残り、章は4ページに延びてしまいます。Markdownでは、`:::columns{count=2 breaks="8"}`が8番目のブロック、つまり見出しから右の段を始めます。段末そろえだけでは高さで切るので、ある動作の説明が2行目に回ると、見出し「Commands and history」が左の段の下端に取り残されます。どの項目も同じ2つのチップ、`Ctrl`と英字で始まり、どちらも等幅書体なので、各動作の説明は段の左端から同じ距離で始まります。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/code-listings-and-keycaps

### script.js

````js
// ═══ 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 = `---
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' }) });

// ─── 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 ───────────────────────────────────────────────────────────────────────
````

## アレンジ

### タブを右に掛ける

タブは囲みの右上の角に移ります。奇数ページでは、そこが余白段の上に当たります。

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

### コメントを文字列と同じ色にする

`rem`チップがなければコメントはイタリックのランになるので、文字列と同じ緑で印刷されます。

```diff
-    else if (comment) out += `:chip[${chipText(comment)}]{style="rem"}`;
+    else if (comment) out += `*${escape(comment)}*`;
```

## よくあるつまずき

- **裸の$は数式を開く。\$と書く.** ドル記号はインライン数式を開くため、$40のような価格は数式の始まりになります。\$40と書いてください。
- **段落の頭の'1998. 'や'- 'はリストになる.** 数字、ピリオド、スペースで始まる段落や、ハイフンとスペースで始まる段落はリスト項目になります。数字の前にワードジョイナー（U+2060）を入れ、会話文はemダッシュで書いてください。
- **ノーブレークスペースでも改行される.** postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める（0.08%）か、文を書き換えてください。
- **:::columnsは囲みの中でしか効かず、分割されない.** :::columnsは囲みの外では無視されます。また、囲みが分割されても、columnsグループの内部で切れることはありません。breaks属性は子ブロックを数え、入れ子の囲みは1つと数えます。
- **サイド段の囲みは、囲みを開く位置の次のブロックと同じ高さから始まる.** postext 1.4.1では、span: 'side'の囲みは、囲みを開く位置で本文が達した高さの次のグリッド行から、すでにある囲みがあればその下に、サイド段に置かれます。注釈は説明する段落の直前で開いてください。段落のあとで開くと、注釈は次の段落の横から始まります。段の末尾を越える囲みは、上の囲みが許す範囲で、下端が段の末尾にそろうまで上にずれます。それでも収まらない囲みは、次のページのサイド段まで待ちます。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **ラントの修正が、描かれないトラッキングを詰めることがある.** postext 1.4.1では、段落がラント（最終行に残った短い一語）で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking（1000分の1 em単位）までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。

- リストのインデントにはスペースを使います。`codeLine()`は行頭のスペースと連続するスペースをノーブレークスペースに変えますが、タブは普通のスペース1つとして印刷されます。
- コードの各行は73字以内に収めます。長い行はスペースの位置で折り返されます。コメントはチップなので分割されず、独立した行に落ちて囲みの端からはみ出します。
- `codeLine()`のエスケープは地の文でも使えます。`1998. The lab opened`のように年号で始まる段落は、先頭にワードジョイナーを置かないとリストの1998番目の項目になります。`\$40`と書けば、価格が数式として読まれません。エムダッシュで始まる会話文にエスケープは要りませんが、ハイフンとスペースで始めるとリストになります。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 書体: Charis SIL (OFL-1.1), Sora (OFL-1.1), JetBrains Mono (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 050 · 安全上の注意を載せた製品の取扱説明書](https://postext.dev/ja/cookbook/product-manual-warnings.md): ドイツ語の電気ケトルの取扱説明書。WARNUNGとVORSICHTの囲みは信号色の帯に警告の三角形を載せ、図と表のラベルもドイツ語です。 · 難易度 2 (中級) · マニュアル・ガイド・リファレンス
- [No. 008 · 色で分けた教科書の囲みのファミリー](https://postext.dev/ja/cookbook/textbook-box-family.md): 教科書の6種類の囲みを3色で組み、アイコン入りの帯、バッジ、番号付きのタブ、ピクトグラムの列、枠の外の目印でそれぞれを見分けられるようにします。 · 難易度 2 (中級) · 教科書
- [No. 022 · 解答欄と語群を備えたワークシート](https://postext.dev/ja/cookbook/worksheet-answer-boxes.md): 4ページの理科のワークシート。淡い緑のカードに白い解答欄を入れ、各問いの2 mm下にグリッドから外して置き、語群と空欄はチップで作ります。 · 難易度 2 (中級) · ワークブックと練習問題
