# Listados de código y teclas sin bloques de código

> Guía de la terminal cuyos bloques de código pasan a ser recuadros oscuros antes de componer, con la negrita y la cursiva como colores y las teclas como chips.

- Versión HTML: https://postext.dev/es/cookbook/code-listings-and-keycaps
- Receta N.º 048 · Recuadros y notas · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Manuales, guías y obras de consulta
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [49](https://postext.dev/cookbook/code-listings-and-keycaps/es/p01.webp?v=3cdc0b73), [50](https://postext.dev/cookbook/code-listings-and-keycaps/es/p02.webp?v=3cdc0b73), [51](https://postext.dev/cookbook/code-listings-and-keycaps/es/p03.webp?v=3cdc0b73)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/code-listings-and-keycaps.md)

## Lo que vas a componer

Tres páginas del capítulo 4 de *La terminal, con calma*, una guía de bolsillo inventada sobre la línea de comandos, en formato de 178 × 229 mm. El texto va justificado en Charis SIL, en una columna de 100 mm, con una columna lateral exterior para las notas. Cada listado es un recuadro casi negro en JetBrains Mono que entra en esa columna, bajo una pestaña con el nombre del archivo o de la sesión. Las palabras clave y las órdenes que tecleas salen en ámbar, las cadenas en verde y los comentarios en gris. Ctrl, Tab y las demás teclas se dibujan dentro de las líneas justificadas, y una chuleta de atajos con Ctrl, a dos columnas, ocupa el pie de la página 50. El Markdown conserva los bloques de código de siempre; una función breve convierte cada uno en un recuadro antes de componer.

**Esta receta responde a:**

- ¿Cómo muestro listados de código y atajos de teclado si no hay bloques de código?
- ¿Cómo hago chips en línea: teclas, etiquetas, bancos de palabras para ejercicios?
- ¿Cómo escribo rayas de diálogo, años al principio de párrafo, precios y símbolos literales sin que el Markdown los malinterprete?

## La respuesta corta

````js
// script.js, líneas 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

**Enseña**

- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso): Estilos de recuadro con nombre para notas, consejos y advertencias: fondo, borde, radio, franja, título y tipografía propia de texto y listas.
- [Escapes y caracteres literales](https://postext.dev/es/docs/document-format.md#normas-de-redacción): Escapes con barra invertida y unidores de palabra que evitan que los dólares, los asteriscos, las rayas de diálogo o un año al principio de párrafo se lean como marcado.
- [Chips en línea](https://postext.dev/es/docs/configuration.md#estilos-de-chip): Cajas redondeadas alrededor de palabras que pasan de línea como una unidad y nunca se estiran: teclas, etiquetas, bancos de palabras, sílabas.

**También usa**

- [Pestañas numeradas en los recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Columnas dentro de un recuadro](https://postext.dev/es/docs/document-format.md#columns)
- [Recuadros a todo el ancho](https://postext.dev/es/docs/configuration.md#el-contenedor-callout)
- [Recuadros flotantes](https://postext.dev/es/docs/configuration.md#el-contenedor-callout)
- [Notas al margen](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Columna y media](https://postext.dev/es/docs/configuration.md#tipos-de-disposición)
- [Columna al margen para flotantes](https://postext.dev/es/docs/configuration.md#disposición)
- [Negrita, cursiva y sus colores](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Listas numeradas](https://postext.dev/es/docs/configuration.md#listas-ordenadas)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Corte de líneas óptimo (Knuth–Plass)](https://postext.dev/es/docs/justification.md#knuth-plass-ver-el-párrafo-completo)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`chipStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-chip), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`orderedLists`](https://postext.dev/es/docs/configuration.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

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

## Elaboración

### 1 · Convierte cada bloque en un recuadro antes de componer

El código de este paso es [la respuesta corta](#la-respuesta-corta) de arriba. Postext 1.4.1 lee un bloque entre acentos graves como Markdown corriente: sus líneas se funden en un solo párrafo, cada par de dólares se convierte en una fórmula, un guion bajo abre una cursiva y el comentario `# Copia cada carpeta…` pasa a ser un título de capítulo. `listings()` reescribe cada bloque como un recuadro `listing` con un párrafo por línea y una barra invertida delante de cada carácter que el Markdown interpretaría. La sangría la conserva el carácter de unión de palabras (U+2060) con que empieza cada línea. Sin él, el analizador recorta los espacios de no separación, el cuerpo del bucle de `copia.sh` pierde la sangría y desaparecen las dos líneas en blanco del guion. Lo que sigue al lenguaje en la línea de apertura (`copia.sh`, `Terminal`) se imprime en la [pestaña del recuadro](/es/docs/configuration#estilos-de-aviso). El párrafo que sigue a un listado va en `:::paragraphs{style="resume"}`, así que empieza sin sangría, como tras un título.

### 2 · Deja el texto estrecho y que el código cruce el margen

```js
// script.js, líneas 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, Charis SIL compone unos 62 caracteres por línea en la columna de 100 mm. La columna lateral de esta [disposición a columna y media](/es/docs/configuration#tipos-de-disposición) solo admite flotantes y notas, así que el texto nunca entra en ella. El `span: 'page'` del estilo del listado (en la respuesta corta) extiende cada listado por las dos columnas, 143 mm. Dentro de su margen interior, un recuadro admite 73 caracteres de JetBrains Mono a 8,6 pt; la línea más larga de estas páginas, la segunda de `copia.sh`, tiene 68.

### 3 · Colorea el código con negrita y cursiva

```js
// script.js, líneas 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 no colorea la sintaxis, pero cada recuadro tiene su propio `boldColor` para la negrita y su `italicColor` para la cursiva, así que `paint()` pone en negrita las palabras clave (ámbar) y en cursiva las cadenas entre comillas (verde). Los comentarios toman un tercer color de `rem`, un estilo de chip que imprime su texto en gris con la letra monoespaciada, sin relleno, contorno, margen interior ni separación. `KEYWORDS` reúne las palabras reservadas del intérprete y algunas órdenes internas (`set`, `echo`, `cd`); los programas, como `mkdir` o `rsync`, quedan en redonda. En un bloque `console`, `session()` pone en negrita lo que tecleas tras el indicador y deja en redonda la respuesta del intérprete.

### 4 · Compón las teclas y el código en línea como chips

```js
// script.js, líneas 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 quita los acentos graves y compone el código en línea con la letra del texto ([formato en línea](/es/docs/document-format#formato-en-línea)), así que `inlineCode()` convierte cada fragmento en un chip `code`: la monoespaciada a 0,88 em, sin caja. Las teclas son chips `key`, con un relleno claro y un contorno de 0,6 pt. Su cuerpo va en puntos, así que miden 7,8 pt en el texto de 10 pt, en la nota del margen de 8,6 pt y en la chuleta de 8,4 pt; con `em(0.78)`, las de la chuleta bajarían a 6,6 pt. Un chip nunca se parte entre líneas ni se estira ([chips en línea](/es/docs/document-format#chips-en-línea)), así que en una línea con teclas todo el hueco sobrante va a los espacios entre palabras. Con `spacing`, el algoritmo de partición de líneas busca otros cortes antes de dejar que un espacio pase del 140 % de su anchura normal. Con el valor por defecto, 200 %, cinco líneas de estas páginas lo superan; con el ajuste, la más abierta llega al 138 %.

### 5 · Numera los pasos sobre la rejilla

```js
// script.js, líneas 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
```

Los números van en Sora 800 con el color de acento, y el separador es un `›` monoespaciado en gris. Como su letra y su color no son los del número, el separador se dibuja como un fragmento propio ([listas ordenadas](/es/docs/configuration#listas-ordenadas)). Los márgenes de la lista son cero, así que los pasos siguen en la rejilla base de 14,5 pt del texto que los rodea. Los 1,5 em por defecto abrirían 5,3 mm sobre los pasos y llevarían la chuleta a la página 51.

### 6 · Lleva la chuleta al pie de la página

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

La chuleta reutiliza el estilo del listado, con Sora para las acciones, y `placement: 'bottom'` la hace flotar hasta el pie de la página 50 ([recuadros flotantes](/es/docs/configuration#el-contenedor-callout)). Dentro del flujo, un recuadro que cruza la página necesita sitio para sí y para dos líneas de texto debajo. Aquí saltaría a la página 51 con 58 mm vacíos bajo los pasos, y el capítulo se iría a cuatro páginas. En el Markdown, `:::columns{count=2 breaks="8"}` abre la columna derecha en el octavo bloque, su título. El equilibrado por sí solo corta por altura, y cuando una acción ocupa dos líneas deja *Órdenes e historial* al pie de la columna izquierda. Cada entrada empieza por los mismos dos chips, `Ctrl` y una letra, los dos en la monoespaciada, así que las acciones arrancan a la misma distancia del borde izquierdo de su columna.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 = 'es'; // @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: "La terminal, con calma"
subtitle: "Guía de bolsillo de la línea de comandos"
author: "Tove Ahlberg"
---

# Piezas que encajan {kicker="Capítulo 4" lead="Cómo la tubería encadena programas sencillos para hacerle preguntas a una carpeta."}

Cada programa de este capítulo hace una sola cosa. \`ls\` muestra los nombres de una carpeta, \`grep\` se queda con las líneas que coinciden con un patrón, \`sort\` las ordena y \`du\` dice cuánto ocupa un archivo en el disco. La tubería, el carácter \`|\`, pasa lo que imprime un programa al siguiente, de modo que puedes encadenarlos en una sola línea y leer la respuesta al final.

:::callout{type="aside" title="El indicador"}
En un Mac, zsh muestra \`%\` en vez de \`$\`.
:::

Pruébalo en una carpeta de fotos. En los listados, las líneas que empiezan por el signo del dólar son las que escribes tú, sin el dólar, y ejecutas con :chip[Intro]{style="key"}. Ese signo es el indicador, que el intérprete de órdenes (la *shell*) muestra cuando te espera. Las de debajo son su respuesta.

\`\`\`console Terminal
$ cd ~/fotos/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\` cuenta las líneas que coinciden sin imprimirlas, y \`-v\` se queda con las demás. Las comillas simples entregan el patrón a \`grep\` tal cual; en él, \`$\` marca el final de la línea. El asterisco de la última orden lo expande el intérprete: antes de que \`du\` arranque, \`*.MOV\` ya se ha convertido en la lista de los archivos \`.MOV\`.
:::

:::callout{type="aside" title="En un Mac"}
:chip[Ctrl]{style="key"} es :chip[control]{style="key"}

:chip[Intro]{style="key"} es :chip[retorno]{style="key"}

Los atajos usan :chip[control]{style="key"}, no :chip[comando]{style="key"}.
:::

## Cuando una orden no termina

Tarde o temprano lanzarás una orden que no acaba. Si escribes \`grep JPG\` sin ningún archivo detrás, \`grep\` se queda esperando a que teclees las líneas en las que debe buscar. Para detenerla, mantén :chip[Ctrl]{style="key"} y pulsa :chip[C]{style="key"}; vuelve el indicador y no ha cambiado nada. Para cerrar la entrada como es debido, pulsa :chip[Ctrl]{style="key"} :chip[D]{style="key"} al principio de una línea vacía; \`grep\` lo entiende como el final de la entrada. :chip[Ctrl]{style="key"} :chip[C]{style="key"} también detiene un \`ping\`, que, si no, escribiría una línea por segundo hasta que cerraras la ventana.

El intérprete también te ahorra teclear. Pulsa :chip[Tab]{style="key"} después de las primeras letras del nombre de un archivo o una carpeta y el intérprete completa el resto; si encajan varios nombres, te los enseña (bash espera a un segundo :chip[Tab]{style="key"}). :chip[↑]{style="key"} recupera la última orden, y cada pulsación retrocede una más, así que una tubería con una errata se corrige en vez de volver a escribirse.

1. Escribe \`cd ~/fo\` y pulsa :chip[Tab]{style="key"} para completar el nombre de la carpeta, \`fotos/\`; luego añade \`2025\` y pulsa :chip[Intro]{style="key"}.
2. Pulsa :chip[↑]{style="key"} hasta que vuelva a la línea \`ls | grep -v 'JPG$'\`.
3. Mantén :chip[Ctrl]{style="key"} y pulsa :chip[A]{style="key"} para saltar al principio de la línea; luego :chip[Ctrl]{style="key"} :chip[E]{style="key"} te devuelve al final.
4. Añade \`| sort -r\` y pulsa :chip[Intro]{style="key"}. Los mismos nombres salen en orden inverso.

:::callout{type="sheet" title="Chuleta · bash y zsh"}
:::columns{count=2 breaks="8"}
**En la línea**

:chip[Ctrl]{style="key"} :chip[A]{style="key"} al principio de la línea

:chip[Ctrl]{style="key"} :chip[E]{style="key"} al final de la línea

:chip[Ctrl]{style="key"} :chip[W]{style="key"} corta la palabra anterior

:chip[Ctrl]{style="key"} :chip[K]{style="key"} corta hasta el final

:chip[Ctrl]{style="key"} :chip[Y]{style="key"} pega lo que cortaste

:chip[Ctrl]{style="key"} :chip[T]{style="key"} cambia dos letras de sitio

**Órdenes e historial**

:chip[Ctrl]{style="key"} :chip[R]{style="key"} busca en órdenes anteriores

:chip[Ctrl]{style="key"} :chip[P]{style="key"} la orden anterior

:chip[Ctrl]{style="key"} :chip[C]{style="key"} detiene la orden en curso

:chip[Ctrl]{style="key"} :chip[Z]{style="key"} la suspende; \`fg\` la reanuda

:chip[Ctrl]{style="key"} :chip[L]{style="key"} limpia la pantalla

:chip[Ctrl]{style="key"} :chip[D]{style="key"} cierra la sesión (línea vacía)
:::
:::

## Un guion para guardar

Las órdenes que escribes cada semana merecen un archivo. Este copia cada carpeta de \`~/trabajo\` en un disco externo, dentro de una carpeta nueva con la fecha del día. Guárdalo como \`copia.sh\` en tu carpeta personal.

\`\`\`bash copia.sh
#!/usr/bin/env bash
# Copia cada carpeta de ~/trabajo en el disco, bajo la fecha de hoy.
set -euo pipefail

src="$HOME/trabajo"
dest="/Volumes/Copias/$(date +%F)"

mkdir -p "$dest"
for dir in "$src"/*/; do
  name=$(basename "$dir")
  rsync -a "$dir" "$dest/$name/"
  echo "copiada: $name"
done
\`\`\`

:::callout{type="aside" title="Modo archivo"}
\`rsync -a\` copia también las subcarpetas y conserva las fechas y los permisos de cada archivo.
:::

:::paragraphs{style="resume"}
La primera línea, el *shebang*, indica con qué programa se ejecuta el archivo. \`set -euo pipefail\` detiene el guion en la primera orden que falla, así que nunca sigue adelante con media copia. \`$(date +%F)\` ejecuta \`date\` y coloca lo que imprime, por ejemplo 2026-09-26, dentro de la ruta. Las comillas alrededor de cada variable mantienen entera una carpeta llamada Declaración de la renta; sin ellas, el intérprete partiría el nombre por los espacios y \`rsync\` buscaría cuatro carpetas que no existen.
:::

Haz el archivo ejecutable con \`chmod +x copia.sh\`, una sola vez, y lánzalo con \`./copia.sh\`. En Linux, un disco externo suele aparecer dentro de \`/media\`, en una carpeta con tu nombre de usuario, así que cambia la línea de \`dest\` para que coincida.

:::callout{type="colophon"}
*La terminal, con calma* es un libro inventado para el Recetario de Postext. Compuesto en Charis SIL, Sora y JetBrains Mono (SIL OFL). Texto: original, CC BY 4.0.
:::

El capítulo 5 lleva \`grep\` a \`/var/log\`, la carpeta de los registros del sistema, donde una tubería de tres órdenes cuenta cuántos errores se anotaron cada día de la última semana.
`; // 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 ───────────────────────────────────────────────────────────────────────
````

## Variantes

### Cuelga la pestaña a la derecha

La pestaña pasa a la esquina superior derecha del recuadro, que en una página impar queda sobre la columna lateral.

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

### Da a los comentarios el color de las cadenas

Sin el chip `rem`, un comentario es una cursiva más y sale en el mismo verde que las cadenas.

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

## Errores frecuentes

- **Un $ suelto abre matemáticas: escribe \$.** El signo de dólar abre matemáticas en línea, así que un precio como $40 empieza una fórmula. Escribe \$40.
- **«1998. » o «- » al principio de un párrafo abren una lista.** Un párrafo que empieza por un número, un punto y un espacio, o por un guion y un espacio, se convierte en un elemento de lista. Pon un unidor de palabras (U+2060) antes del número y escribe los diálogos con raya.
- **Un espacio de no separación sigue partiendo la línea.** En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase.
- **:::columns solo funciona dentro de un recuadro y no se parte.** :::columns se ignora fuera de un recuadro, y un recuadro que se parte nunca corta dentro de un grupo de columnas. El atributo breaks cuenta bloques hijos, y un recuadro anidado cuenta como uno.
- **Un recuadro lateral empieza a la altura del bloque que sigue a su valla.** En postext 1.4.1, un recuadro con span: 'side' se coloca en la columna lateral a la altura a la que ha llegado el texto en su valla, en la siguiente línea de la rejilla y debajo de los recuadros que ya haya. Pon la valla de una glosa justo antes del párrafo que explica: si va después, la glosa empieza junto al párrafo siguiente. Un recuadro que pasaría del pie de la columna sube hasta que su pie coincide con el de la columna, si el recuadro de encima le deja sitio; si aun así no cabe, espera a la columna lateral de la página siguiente.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Solo 8 idiomas tienen separación silábica, con el código exacto.** La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano.
- **El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta.** En postext 1.4.1, cuando un párrafo acaba en una línea corta, el motor lo compone con una línea menos: primero aprieta el espacio entre palabras y luego aplica hasta maxRuntTracking milésimas de em de interletraje negativo. Los renderizadores de canvas y PDF solo pintan el interletraje mayor que cero, así que el párrafo se imprime sin él: sus líneas justificadas pierden esa diferencia en los espacios entre palabras, que salen aplastados, y su última línea puede pasarse de la medida y quedar cortada en el borde de la columna. Pon bodyText.maxRuntTracking: 0, que conserva el arreglo por el espacio entre palabras, y reescribe los párrafos que vuelvan a acabar en una línea corta.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().

- Sangra los listados con espacios. `codeLine()` convierte los espacios iniciales y las series de espacios en espacios de no separación, mientras que un tabulador se imprime como un espacio corriente.
- No pases de 73 caracteres en ninguna línea de código. Una línea más larga se parte por un espacio, y un comentario, que es un chip, nunca se parte: baja a una línea propia y se sale del borde del recuadro.
- Los escapes de `codeLine()` también sirven en la prosa. Un párrafo que empieza por un año, como `1998. Se inaugura el laboratorio`, se convierte en el elemento 1998 de una lista si no lleva delante un carácter de unión de palabras, y `\$40` impide que un precio abra una fórmula. Un diálogo que empieza con raya no necesita escape; un guion y un espacio abrirían una lista.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Charis SIL (OFL-1.1), Sora (OFL-1.1), JetBrains Mono (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 050 · Manual de producto con avisos de seguridad](https://postext.dev/es/cookbook/product-manual-warnings.md): Manual de un hervidor en alemán: los recuadros WARNUNG y VORSICHT llevan el triángulo en una franja del color de aviso, y los pies dicen Abbildung y Tabelle. · Nivel 2 (Intermedio) · Manuales, guías y obras de consulta
- [N.º 008 · Recuadros de libro de texto con código de colores](https://postext.dev/es/cookbook/textbook-box-family.md): Seis tipos de recuadro en tres colores, que se distinguen por una franja con icono, un distintivo, una pestaña numerada, pictogramas o una marca lateral. · Nivel 2 (Intermedio) · Libros de texto
- [N.º 022 · Ficha con cajas de respuesta y banco de palabras](https://postext.dev/es/cookbook/worksheet-answer-boxes.md): Una ficha de ciencias de cuatro páginas: cajas de respuesta blancas en tarjetas verde claro, a 2 mm de cada pregunta, y chips para bancos de palabras y huecos. · Nivel 2 (Intermedio) · Cuadernos y ejercicios
