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?
- ¿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
// 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
- Funciones
- RecuadrosEscapes y caracteres literalesChips en líneaPestañas numeradas en los recuadrosColumnas dentro de un recuadroRecuadros a todo el anchoRecuadros flotantesNotas al margenColumna y mediaColumna al margen para flotantesNegrita, cursiva y sus coloresListas numeradasAperturas diseñadasAtributos de títuloCabeceras y foliosCabeceras según el tipo de páginaMárgenes simétricosPaleta de color semánticaEstilos de párrafoCorte de líneas óptimo (Knuth–Plass)
- Tipografía
- Charis SIL, Sora, JetBrains Mono (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Convierte cada bloque en un recuadro antes de componer
El código de este paso es 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. 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
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 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
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
// 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), 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), 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
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). 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
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). 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
// ═══ 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 = `---Muestra en Markdown · 118 líneas · content.es.md
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 · core, fonts, viewer: igual en todas las recetas · 235 líneas
// ─── 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 ───────────────────────────────────────────────────────────────────────
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗
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.
- 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.
- else if (comment) out += `:chip[${chipText(comment)}]{style="rem"}`;
+ else if (comment) out += `*${escape(comment)}*`;Errores frecuentes
Error frecuente
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. Escapes y caracteres literales →
Error frecuente
«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. Escapes y caracteres literales →
Error frecuente
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. Escapes y caracteres literales →
Error frecuente
:::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. Columnas dentro de un recuadro →
Error frecuente
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. Notas al margen →
Error frecuente
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. Paleta de color semántica →
Error frecuente
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. Capítulos que abren en página impar →
Error frecuente
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. Separación silábica e idioma del documento →
Error frecuente
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. Viudas, huérfanas y líneas cortas →
Error frecuente
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. Textos, filetes y cajas en los diseños de página →
Error frecuente
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. Fuentes antes de componer →
Error frecuente
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(). Páginas en un canvas →
- 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, como1998. 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\$40impide 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
- Texto
- Texto original, CC BY 4.0
- Fuentes
- Charis SIL (SIL OFL 1.1) · Sora (SIL OFL 1.1) · JetBrains Mono (SIL OFL 1.1)
- Código
- MIT, como Postext


