# Listagens de código e teclas sem blocos de código

> Um guia do terminal cujos blocos de código viram boxes escuros antes da composição, com negrito e itálico como cores de sintaxe e as teclas como chips.

- Versão HTML: https://postext.dev/pt/cookbook/code-listings-and-keycaps
- Receita Nº 048 · Boxes e notas · Nível 2 (Intermediário) · Saídas: Canvas
- Gêneros: Manuais, guias e obras de referência
- Requer postext ≥ 1.4.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [49](https://postext.dev/cookbook/code-listings-and-keycaps/en/p01.webp?v=0c86805c), [50](https://postext.dev/cookbook/code-listings-and-keycaps/en/p02.webp?v=0c86805c), [51](https://postext.dev/cookbook/code-listings-and-keycaps/en/p03.webp?v=0c86805c)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=code-listings-and-keycaps&lang=en (.postext: https://postext.dev/cookbook/code-listings-and-keycaps/en/code-listings-and-keycaps.postext)
- Última atualização: 2026-09-26
- Outros idiomas: [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), [ja](https://postext.dev/ja/cookbook/code-listings-and-keycaps.md), [ar](https://postext.dev/ar/cookbook/code-listings-and-keycaps.md)

## Em poucas palavras

Três páginas de um guia para iniciantes na linha de comando do computador. Mostra como transformar exemplos de código em caixas escuras coloridas e desenhar teclas como Ctrl como botõezinhos no texto.

## O que você vai compor

Três páginas do capítulo 4 de *The Shell, Gently*, um guia de bolso inventado sobre a linha de comando, numa página de 178 × 229 mm. O texto vai justificado em Charis SIL, numa coluna de 100 mm, com uma coluna lateral do lado externo para as notas. Cada listagem é um boxe quase preto em JetBrains Mono que entra nessa coluna, sob uma aba com o nome do arquivo ou da sessão. Palavras-chave e comandos digitados saem em âmbar, strings em verde e comentários em cinza. Teclas como Ctrl e Tab são pequenas teclas contornadas dentro das linhas justificadas, e uma colinha de atalhos com Ctrl, em duas colunas, ocupa o pé da página 50. O Markdown mantém os blocos de código cercados de sempre; uma função curta transforma cada um num boxe antes da composição.

**Esta receita responde a:**

- Como mostro listagens de código e atalhos de teclado se não há suporte a blocos de código?
- Como faço chips em linha: teclas, etiquetas, bancos de palavras para exercícios?
- Como escrevo travessões de diálogo, anos no início do parágrafo, preços e símbolos literais sem que o Markdown os interprete errado?

## A resposta curta

````js
// script.js, linhas 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 },
};
````

## Ingredientes

**Ensina**

- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe): Estilos de boxe com nome para notas, dicas e advertências: fundo, borda, raio, faixa, título e tipografia própria para o texto e as listas.
- [Escapes e caracteres literais](https://postext.dev/pt/docs/document-format.md#convenções-de-escrita): Escapes com barra invertida e word joiners que impedem que cifrões, asteriscos, travessões de diálogo e anos no início de um parágrafo sejam lidos como marcação.
- [Chips no texto](https://postext.dev/pt/docs/configuration.md#estilos-de-chip): Caixas arredondadas em volta de palavras, que mudam de linha como uma unidade e nunca se esticam: teclas, etiquetas, bancos de palavras, sílabas.

**Também usa**

- [Abas numeradas nos boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Colunas dentro de um boxe](https://postext.dev/pt/docs/document-format.md#columns)
- [Boxes na largura da página](https://postext.dev/pt/docs/configuration.md#o-contêiner-callout)
- [Boxes flutuantes](https://postext.dev/pt/docs/configuration.md#o-contêiner-callout)
- [Notas na margem](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Coluna e meia](https://postext.dev/pt/docs/configuration.md#tipos-de-layout)
- [Coluna de margem para flutuantes](https://postext.dev/pt/docs/configuration.md#diagramação)
- [Negrito, itálico e suas cores](https://postext.dev/pt/docs/configuration.md#texto-do-corpo)
- [Listas numeradas](https://postext.dev/pt/docs/configuration.md#listas-numeradas)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Quebra de linha ótima (Knuth–Plass)](https://postext.dev/pt/docs/justification.md#knuth-plass-o-parágrafo-inteiro-de-uma-vez)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe), [`chipStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-chip), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`locale`](https://postext.dev/pt/docs/configuration.md#hifenização), [`orderedLists`](https://postext.dev/pt/docs/configuration.md#listas-numeradas), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)

**Tipos**

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

## Preparo

### 1 · Transformar cada bloco cercado num boxe antes da composição

O código deste passo é [a resposta curta](https://postext.dev/pt/cookbook/code-listings-and-keycaps.md#a-resposta-curta) lá em cima. O Postext 1.4.1 lê um bloco cercado como Markdown comum: as suas linhas se juntam num só parágrafo, cada par de cifrões vira uma fórmula, um sublinhado abre itálico e o comentário `# Copy each folder…` vira um título de capítulo. `listings()` reescreve cada bloco como um boxe `listing` com um parágrafo por linha e uma barra invertida antes de cada caractere que o Markdown interpretaria. O word joiner (U+2060) que abre cada linha preserva o recuo. Sem ele, o analisador apara os espaços não separáveis, o corpo do laço de `backup.sh` perde o recuo e as duas linhas em branco do script somem. O que vem depois da linguagem na linha de abertura do bloco (`backup.sh`, `Terminal`) é impresso na [aba de rótulo](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe) do boxe. O parágrafo depois de uma listagem vai em `:::paragraphs{style="resume"}`, então começa sem recuo, como depois de um título.

### 2 · Manter o texto estreito e deixar o código atravessar a margem

```js
// script.js, linhas 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' },
```

A 10 pt, a Charis SIL compõe cerca de 62 caracteres na coluna de 100 mm. A coluna lateral deste [layout de coluna e meia](https://postext.dev/pt/docs/configuration.md#tipos-de-layout) só recebe flutuantes e notas, então o texto nunca entra nela. O `span: 'page'` do estilo da listagem (na resposta curta) estende cada listagem pelas duas colunas, 143 mm. Dentro do preenchimento, um boxe comporta 73 caracteres de JetBrains Mono a 8,6 pt; a linha mais longa destas páginas, a segunda de `backup.sh`, tem 71.

### 3 · Colorir o código com negrito e itálico

```js
// script.js, linhas 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);
```

O Postext não tem realce de sintaxe, mas um boxe imprime os seus trechos em negrito no seu próprio `boldColor` e os trechos em itálico no `italicColor`, então `paint()` põe as palavras-chave em negrito (âmbar) e as strings entre aspas em itálico (verde). Os comentários recebem uma terceira cor de `rem`, um estilo de chip sem preenchimento, contorno, espaçamento interno nem margem, que imprime o texto em cinza na fonte monoespaçada. `KEYWORDS` lista as palavras reservadas do shell e alguns comandos internos (`set`, `echo`, `cd`); programas como `mkdir` e `rsync` ficam sem destaque. Num bloco `console`, `session()` põe em negrito o que você digita depois do prompt e deixa sem destaque a resposta do shell.

### 4 · Compor teclas e código em linha como chips

```js
// script.js, linhas 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
];
```

O Postext descarta os acentos graves e compõe o código em linha na fonte do texto ([formatação em linha](https://postext.dev/pt/docs/document-format.md#formatação-em-linha)), então `inlineCode()` transforma cada trecho num chip `code`: a monoespaçada a 0,88 em, sem caixa. As teclas são chips `key`, com um preenchimento claro e um contorno de 0,6 pt. O tamanho delas é dado em pontos, então uma tecla mede 7,8 pt no texto de 10 pt, na nota de margem de 8,6 pt e na colinha de 8,4 pt; `em(0.78)` encolheria as teclas da colinha para 6,6 pt. Um chip nunca se divide entre linhas nem se estica ([chips em linha](https://postext.dev/pt/docs/document-format.md#chips-em-linha)), então uma linha com teclas joga toda a folga nos espaços entre palavras. `spacing` faz o algoritmo de quebra de linhas tentar outras quebras antes de deixar um espaço passar de 140 % da sua largura normal. No padrão, 200 %, seis linhas destas páginas se esticam além disso; com o ajuste, a mais aberta chega a 132 %.

### 5 · Numerar os passos na grade

```js
// script.js, linhas 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
```

Os números são Sora 800 na cor de destaque, e o separador é um `›` monoespaçado em cinza. O separador tem fonte e cor próprias, então é desenhado como um trecho separado ([listas numeradas](https://postext.dev/pt/docs/configuration.md#listas-numeradas)). As margens da lista são zero, então os passos continuam na grade de 14,5 pt do texto ao redor. O 1,5 em padrão abriria 5,3 mm acima dos passos e empurraria a colinha para a página 51.

### 6 · Levar a colinha flutuando até o pé da página

```js
// script.js, linhas 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) } };
```

A colinha reaproveita o estilo da listagem, com Sora para as ações, e `placement: 'bottom'` a faz flutuar até o pé da página 50 ([boxes flutuantes](https://postext.dev/pt/docs/configuration.md#o-contêiner-callout)). Deixado no fluxo, um boxe na largura da página precisa de espaço para si e para duas linhas de texto embaixo. Aqui o boxe passaria para a página 51, deixando 58 mm vazios sob os passos, e o capítulo iria para quatro páginas. No Markdown, `:::columns{count=2 breaks="8"}` abre a coluna da direita no oitavo bloco, o seu título. O balanceamento sozinho corta pela altura, e quando uma ação ocupa uma segunda linha ele deixa *Commands and history* no pé da coluna da esquerda. Toda entrada começa com os mesmos dois chips, `Ctrl` e uma letra, ambos na monoespaçada, então cada ação começa à mesma distância da borda esquerda da sua coluna.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: 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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** 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.
 *  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',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  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 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 = `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` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds 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. */
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' };
}

/** 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, '-'); }

/** 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 v1 ── 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. */
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 ───────────────────────────────────────────────────────────────────────
````

## Variações

### Pendurar a aba à direita

A aba passa para o canto superior direito do boxe, que num recto fica sobre a coluna lateral.

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

### Dar aos comentários a cor das strings

Sem o chip `rem`, um comentário é um trecho em itálico, então sai no mesmo verde das strings.

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

## Erros comuns

- **Um $ solto abre matemática: escreva \$.** O cifrão abre matemática em linha, então um preço como $40 inicia uma fórmula. Escreva \$40.
- **'1998. ' ou '- ' no início de um parágrafo abre uma lista.** Um parágrafo que começa com um número, um ponto e um espaço, ou com um hífen e um espaço, vira item de lista. Coloque um word joiner (U+2060) antes do número e escreva os diálogos com travessão.
- **Um espaço não separável ainda quebra a linha.** No postext 1.4.1, o algoritmo de quebra de linha trata U+00A0 como um espaço comum, então 0,08 %, 2,006 s ou seção 2 podem ficar em duas linhas. Junte os dois elementos (0,08%) ou reescreva a frase.
- **:::columns só funciona dentro de um boxe e nunca se divide.** :::columns é ignorado fora de um boxe, e um boxe que se divide nunca corta dentro de um grupo de colunas. O atributo breaks conta blocos filhos, e um boxe aninhado conta como um.
- **Um boxe lateral começa na altura do bloco que vem depois do seu delimitador.** No postext 1.4.1, um boxe com span: 'side' fica na coluna lateral na altura a que o texto chegou no seu delimitador, na linha seguinte da grade e abaixo dos boxes que já estiverem ali. Abra o delimitador de uma glosa logo antes do parágrafo que ela explica: se vier depois, a glosa começa ao lado do parágrafo seguinte. Um boxe que passaria do pé da coluna sobe até seu pé coincidir com o da coluna, se o boxe de cima permitir; se mesmo assim não couber, espera pela coluna lateral da página seguinte.
- **Uma paleta trocada não chega aos elementos de design nem à cor das referências.** postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor.
- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **Só 8 idiomas têm hifenização, com o código exato.** A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano.
- **O ajuste de linhas curtas pode apertar um tracking que nunca é pintado.** No postext 1.4.1, quando um parágrafo termina numa linha curta, a diagramação o compõe com uma linha a menos: primeiro aperta o espaçamento entre palavras, depois aplica até maxRuntTracking milésimos de em de tracking negativo. Os renderizadores de canvas e PDF só pintam tracking acima de zero, então o parágrafo sai impresso sem ele: as linhas justificadas perdem essa diferença nos espaços entre palavras, que ficam esmagados, e a última linha pode passar da medida e ser cortada na borda da coluna. Defina bodyText.maxRuntTracking: 0, que mantém o ajuste pelo espaçamento entre palavras, e reescreva os parágrafos que voltarem a terminar numa linha curta.
- **O lineHeight de um texto de design é um múltiplo, nunca uma medida.** Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título.
- **Carregue todas as fontes antes do layout.** O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada.
- **A configuração fica em cache pela identidade: crie um objeto novo.** O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config().

- Recue as listagens com espaços. `codeLine()` transforma os espaços iniciais e as sequências de espaços em espaços não separáveis, enquanto uma tabulação sai como um espaço comum.
- Mantenha toda linha de código em até 73 caracteres. Uma linha mais longa quebra num espaço, e um comentário, que é um chip, nunca quebra: ele desce para uma linha só sua e passa da borda do boxe.
- Os escapes de `codeLine()` também valem na prosa. Um parágrafo que começa com um ano, como `1998. The lab opened`, vira o item 1998 de uma lista se não vier antes um word joiner, e `\$40` impede que um preço vire fórmula. Um diálogo que começa com travessão não precisa de escape; um hífen e um espaço abririam uma lista.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipos: Charis SIL (OFL-1.1), Sora (OFL-1.1), JetBrains Mono (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 050 · Manual de produto com avisos de segurança](https://postext.dev/pt/cookbook/product-manual-warnings.md): O manual de uma chaleira em alemão: os boxes WARNUNG e VORSICHT levam o triângulo numa faixa da cor de alerta, e as legendas dizem Abbildung e Tabelle. · Nível 2 (Intermediário) · Manuais, guias e obras de referência
- [Nº 008 · Uma família de boxes de livro didático com código de cores](https://postext.dev/pt/cookbook/textbook-box-family.md): Seis tipos de boxe de livro didático em três cores, diferenciados por faixa com ícone, selo, aba numerada, pictogramas ou um indicador fora da moldura. · Nível 2 (Intermediário) · Livros didáticos
- [Nº 022 · Atividade com caixas de resposta e banco de palavras](https://postext.dev/pt/cookbook/worksheet-answer-boxes.md): Uma folha de ciências de quatro páginas: caixas de resposta brancas em cartões verde-claros, 2 mm abaixo de cada pergunta e fora da grade, e lacunas em chips. · Nível 2 (Intermediário) · Cadernos de exercícios
