Em poucas palavras
Três páginas de um guia para iniciantes na linha de comando do computador. Mostra como transformar exemplos de código em caixas escuras coloridas e desenhar teclas como Ctrl como botõezinhos no texto.
O que você vai compor
Três páginas do capítulo 4 de The Shell, Gently, um guia de bolso inventado sobre a linha de comando, numa página de 178 × 229 mm. O texto vai justificado em Charis SIL, numa coluna de 100 mm, com uma coluna lateral do lado externo para as notas. Cada listagem é um boxe quase preto em JetBrains Mono que entra nessa coluna, sob uma aba com o nome do arquivo ou da sessão. Palavras-chave e comandos digitados saem em âmbar, strings em verde e comentários em cinza. Teclas como Ctrl e Tab são pequenas teclas contornadas dentro das linhas justificadas, e uma colinha de atalhos com Ctrl, em duas colunas, ocupa o pé da página 50. O Markdown mantém os blocos de código cercados de sempre; uma função curta transforma cada um num boxe antes da composição.
Esta receita responde a
- Como mostro listagens de código?
- Como faço chips em linha: teclas, etiquetas, bancos de palavras para exercícios?
- Como escrevo travessões de diálogo, anos no início do parágrafo, preços e símbolos literais sem que o Markdown os interprete errado?
A resposta curta
// 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
- Funcionalidades
- BoxesEscapes e caracteres literaisChips no textoAbas numeradas nos boxesColunas dentro de um boxeBoxes na largura da páginaBoxes flutuantesNotas na margemColuna e meiaColuna de margem para flutuantesNegrito, itálico e suas coresListas numeradasAberturas desenhadasAtributos de títuloCabeços e fóliosCabeços por tipo de páginaMargens espelhadasPaleta de cores semânticaEstilos de parágrafoQuebra de linha ótima (Knuth–Plass)
- Tipografia
- Charis SIL, Sora, JetBrains Mono (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Transformar cada bloco cercado num boxe antes da composição
O código deste passo é a resposta curta lá em cima. O Postext 1.4.1 lê um bloco cercado como Markdown comum: as suas linhas se juntam num só parágrafo, cada par de cifrões vira uma fórmula, um sublinhado abre itálico e o comentário # Copy each folder… vira um título de capítulo. listings() reescreve cada bloco como um boxe listing com um parágrafo por linha e uma barra invertida antes de cada caractere que o Markdown interpretaria. O word joiner (U+2060) que abre cada linha preserva o recuo. Sem ele, o analisador apara os espaços não separáveis, o corpo do laço de backup.sh perde o recuo e as duas linhas em branco do script somem. O que vem depois da linguagem na linha de abertura do bloco (backup.sh, Terminal) é impresso na aba de rótulo do boxe. O parágrafo depois de uma listagem vai em :::paragraphs{style="resume"}, então começa sem recuo, como depois de um título.
#2 · Manter o texto estreito e deixar o código atravessar a margem
page: { sizePreset: 'custom', width: mm(178), height: mm(229), margins: {
top: mm(22), bottom: mm(21), left: mm(20), right: mm(OUTER), mirror: true } },
layout: { layoutType: 'oneAndHalf', sideColumnPercent: 26, gutterWidth: mm(6),
sideColumnRole: 'floats', sideColumnSide: 'outer' },
A 10 pt, a Charis SIL compõe cerca de 62 caracteres na coluna de 100 mm. A coluna lateral deste layout de coluna e meia só recebe flutuantes e notas, então o texto nunca entra nela. O span: 'page' do estilo da listagem (na resposta curta) estende cada listagem pelas duas colunas, 143 mm. Dentro do preenchimento, um boxe comporta 73 caracteres de JetBrains Mono a 8,6 pt; a linha mais longa destas páginas, a segunda de backup.sh, tem 71.
#3 · Colorir o código com negrito e itálico
const KEYWORDS = 'if|then|else|elif|fi|for|in|do|done|while|until|case|esac' // reserved words
+ '|set|echo|cd|export|local|read'; // builtins; programs such as mkdir and rsync stay plain
const TOKEN = new RegExp(`("(?:\\\\.|[^"\\\\])*"|'[^']*')` // a quoted string
+ `|((?:^|(?<=\\s))#.*$)|\\b(${KEYWORDS})\\b`, 'g'); // a comment, a keyword
function paint(line) {
let out = '';
let last = 0;
for (const { 0: token, 1: string, 2: comment, index } of line.matchAll(TOKEN)) {
out += escape(line.slice(last, index));
if (string) out += `*${escape(string)}*`;
else if (comment) out += `:chip[${chipText(comment)}]{style="rem"}`;
else out += `**${token}**`;
last = index + token.length;
}
return out + escape(line.slice(last));
}
// In a session, what you type after the prompt is bold; the shell's answer stays plain.
const session = (line) => line.startsWith('$ ') ? `\\$ **${escape(line.slice(2))}**` : escape(line);
O Postext não tem realce de sintaxe, mas um boxe imprime os seus trechos em negrito no seu próprio boldColor e os trechos em itálico no italicColor, então paint() põe as palavras-chave em negrito (âmbar) e as strings entre aspas em itálico (verde). Os comentários recebem uma terceira cor de rem, um estilo de chip sem preenchimento, contorno, espaçamento interno nem margem, que imprime o texto em cinza na fonte monoespaçada. KEYWORDS lista as palavras reservadas do shell e alguns comandos internos (set, echo, cd); programas como mkdir e rsync ficam sem destaque. Num bloco console, session() põe em negrito o que você digita depois do prompt e deixa sem destaque a resposta do shell.
#4 · Compor teclas e código em linha como chips
// Chips never break or stretch, so a line with keys puts all its slack in its word spaces;
// the breaker tries other breaks before a space passes 140 % (default 200 %). Inside a chip
// maths stays literal, so '$' needs no backslash there, but ']' does.
const spacing = { maxWordSpacing: 1.4 }; // spread into bodyText
const chipText = (text) => text.replace(/[*_^~`]/g, '\\$&').replace(/]/g, '\\]');
const inlineCode = (markdown) => markdown.replace(/(?<!\\)`([^`\n]+)`/g,
(_, code) => `:chip[${chipText(code)}]{style="code"}`);
const bare = { backgroundEnabled: false, borderWidth: ZERO, paddingX: ZERO, gap: ZERO };
const chipStyles = [
{ id: 'key', fontFamily: MONO, fontSize: pt(7.8), bold: true, color: col('ink'),
background: col('code'), borderColor: col('slate'), borderWidth: pt(0.6),
borderRadius: pt(1.6), paddingX: em(0.45), paddingY: em(0.14), gap: em(0.3) },
{ id: 'code', fontFamily: MONO, fontSize: em(0.88), ...bare }, // `grep` in running text
{ id: 'rem', fontFamily: MONO, color: col('slate'), ...bare }, // a comment in a listing
];
O Postext descarta os acentos graves e compõe o código em linha na fonte do texto (formatação em linha), então inlineCode() transforma cada trecho num chip code: a monoespaçada a 0,88 em, sem caixa. As teclas são chips key, com um preenchimento claro e um contorno de 0,6 pt. O tamanho delas é dado em pontos, então uma tecla mede 7,8 pt no texto de 10 pt, na nota de margem de 8,6 pt e na colinha de 8,4 pt; em(0.78) encolheria as teclas da colinha para 6,6 pt. Um chip nunca se divide entre linhas nem se estica (chips em linha), então uma linha com teclas joga toda a folga nos espaços entre palavras. spacing faz o algoritmo de quebra de linhas tentar outras quebras antes de deixar um espaço passar de 140 % da sua largura normal. No padrão, 200 %, seis linhas destas páginas se esticam além disso; com o ajuste, a mais aberta chega a 132 %.
#5 · Numerar os passos na grade
const orderedLists = { fontFamily: DISPLAY, fontWeight: 800, color: col('ember'),
gap: em(0.7), separator: '›', separatorGap: em(0.25), separatorFontFamily: MONO,
separatorFontWeight: 700, separatorColor: col('muted'),
marginTop: ZERO, marginBottom: ZERO }; // the default 1.5 em opens 5.3 mm above and below
Os números são Sora 800 na cor de destaque, e o separador é um › monoespaçado em cinza. O separador tem fonte e cor próprias, então é desenhado como um trecho separado (listas numeradas). As margens da lista são zero, então os passos continuam na grade de 14,5 pt do texto ao redor. O 1,5 em padrão abriria 5,3 mm acima dos passos e empurraria a colinha para a página 51.
#6 · Levar a colinha flutuando até o pé da página
const sheet = { ...listing, id: 'sheet', label: undefined, placement: 'bottom',
columnGap: mm(8), padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
titleStyle: { fontFamily: MONO, fontSize: pt(7.5), fontWeight: 700, gap: mm(3.5),
color: col('phosphor'), textTransform: 'uppercase', letterSpacing: pt(1.5) },
body: { ...listing.body, fontFamily: DISPLAY, fontSize: pt(8.4), lineHeight: pt(13) } };
A colinha reaproveita o estilo da listagem, com Sora para as ações, e placement: 'bottom' a faz flutuar até o pé da página 50 (boxes flutuantes). Deixado no fluxo, um boxe na largura da página precisa de espaço para si e para duas linhas de texto embaixo. Aqui o boxe passaria para a página 51, deixando 58 mm vazios sob os passos, e o capítulo iria para quatro páginas. No Markdown, :::columns{count=2 breaks="8"} abre a coluna da direita no oitavo bloco, o seu título. O balanceamento sozinho corta pela altura, e quando uma ação ocupa uma segunda linha ele deixa Commands and history no pé da coluna da esquerda. Toda entrada começa com os mesmos dois chips, Ctrl e uma letra, ambos na monoespaçada, então cada ação começa à mesma distância da borda esquerda da sua coluna.
A receita completa
// ═══ Postext Cookbook · Nº 048 · Code listings and keycaps without code blocks ═══ // https://postext.dev/en/cookbook/code-listings-and-keycaps // Code: MIT · Text: original (CC BY 4.0) · Pictures: none // Fonts: Charis SIL, Sora, JetBrains Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'code-listings-and-keycaps'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1b1f24', muted: '#5c636b', ember: '#9a5410', // text, heads, accent night: '#0e1116', code: '#d3d9df', amber: '#f2b134', phosphor: '#3ddc84', // the listings slate: '#8a939d' }; // comments in a listing, the outline of a key (code is its face) // Design elements read the hex, not the palette id (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. const colorPalette = Object.entries({ ...palette, 'main-color': palette.ember }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, MONO] = ['Charis SIL', 'Sora', 'JetBrains Mono']; const LEAD = 14.5; // pt: the body leading, the page's baseline grid const [NBSP, ZERO] = ['\u00a0', pt(0)]; // #region answer: a fenced block becomes a dark box with one escaped paragraph per line // Postext sets no fenced code, so the Markdown is rewritten before the build: // ```bash backup.sh … ``` → :::callout{type="listing" label="backup.sh"} … ::: // Characters Markdown would read as emphasis, a superscript or subscript, code or maths get // a backslash (gotcha: dollar-math). The parser drops a backslash only before those, so any // other backslash in the code prints as typed. ']\u2060(' keeps '[a](b)' from becoming a link. const escape = (text) => text.replace(/[*_^~`$]/g, '\\$&').replace(/\]\(/g, ']\u2060('); function codeLine(line, lang) { // A word joiner (U+2060) opens every line, so a leading '#', '-', '1.' or '>' stays text // (gotcha: digit-period-list). Parsing trims leading spaces, no-break ones included; // the word joiner in front keeps them. const indent = line.match(/^ */)[0].length; // indent listings with spaces, not tabs const body = (lang === 'console' ? session : paint)(line.slice(indent)); const runs = body.replace(/ {2,}/g, (run) => NBSP.repeat(run.length)); // output columns return `\u2060${NBSP.repeat(indent)}${runs}`; } // The label stops at a double quote, which would close the attribute. const listings = (markdown) => markdown.replace(/^```(\w*) *([^"\n]*).*\n([\s\S]*?)^```$/gm, (_, lang, label, code) => [`:::callout{type="listing" label="${label || lang}"}`, // One paragraph per line; a blank line keeps the word joiner alone. ...code.replace(/\n$/, '').split('\n').map((line) => codeLine(line, lang)), ':::', ].join('\n\n')); // The text after a listing goes in :::paragraphs{style="resume"}: flush, as after a heading. const resume = { id: 'resume', firstLineIndent: ZERO }; const listing = { id: 'listing', background: col('night'), span: 'page', // across the text and the margin padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) }, marginTop: mm(6), marginBottom: mm(2.5), label: { fontFamily: MONO, fontSize: pt(7), fontWeight: 700, color: col('phosphor'), background: col('night'), height: mm(5), offset: mm(5), paddingX: mm(3), // the tab position: 'top-left' }, body: { fontFamily: MONO, fontSize: pt(8.6), lineHeight: pt(12.4), textAlign: 'left', color: col('code'), boldColor: col('amber'), italicColor: col('phosphor'), // paint() // One paragraph per line of code: no space between them, even if bodyText adds some. paragraphSpacing: false, firstLineIndent: ZERO }, }; // #endregion // #region paint: keywords bold, strings italic, comments a chip with no box const KEYWORDS = 'if|then|else|elif|fi|for|in|do|done|while|until|case|esac' // reserved words + '|set|echo|cd|export|local|read'; // builtins; programs such as mkdir and rsync stay plain const TOKEN = new RegExp(`("(?:\\\\.|[^"\\\\])*"|'[^']*')` // a quoted string + `|((?:^|(?<=\\s))#.*$)|\\b(${KEYWORDS})\\b`, 'g'); // a comment, a keyword function paint(line) { let out = ''; let last = 0; for (const { 0: token, 1: string, 2: comment, index } of line.matchAll(TOKEN)) { out += escape(line.slice(last, index)); if (string) out += `*${escape(string)}*`; else if (comment) out += `:chip[${chipText(comment)}]{style="rem"}`; else out += `**${token}**`; last = index + token.length; } return out + escape(line.slice(last)); } // In a session, what you type after the prompt is bold; the shell's answer stays plain. const session = (line) => line.startsWith('$ ') ? `\\$ **${escape(line.slice(2))}**` : escape(line); // #endregion // #region keycaps: keys, and inline code in the mono face, are chips // Chips never break or stretch, so a line with keys puts all its slack in its word spaces; // the breaker tries other breaks before a space passes 140 % (default 200 %). Inside a chip // maths stays literal, so '$' needs no backslash there, but ']' does. const spacing = { maxWordSpacing: 1.4 }; // spread into bodyText const chipText = (text) => text.replace(/[*_^~`]/g, '\\$&').replace(/]/g, '\\]'); const inlineCode = (markdown) => markdown.replace(/(?<!\\)`([^`\n]+)`/g, (_, code) => `:chip[${chipText(code)}]{style="code"}`); const bare = { backgroundEnabled: false, borderWidth: ZERO, paddingX: ZERO, gap: ZERO }; const chipStyles = [ { id: 'key', fontFamily: MONO, fontSize: pt(7.8), bold: true, color: col('ink'), background: col('code'), borderColor: col('slate'), borderWidth: pt(0.6), borderRadius: pt(1.6), paddingX: em(0.45), paddingY: em(0.14), gap: em(0.3) }, { id: 'code', fontFamily: MONO, fontSize: em(0.88), ...bare }, // `grep` in running text { id: 'rem', fontFamily: MONO, color: col('slate'), ...bare }, // a comment in a listing ]; // #endregion // #region sheet: a two-column cheat sheet floated to the foot of its page const sheet = { ...listing, id: 'sheet', label: undefined, placement: 'bottom', columnGap: mm(8), padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) }, titleStyle: { fontFamily: MONO, fontSize: pt(7.5), fontWeight: 700, gap: mm(3.5), color: col('phosphor'), textTransform: 'uppercase', letterSpacing: pt(1.5) }, body: { ...listing.body, fontFamily: DISPLAY, fontSize: pt(8.4), lineHeight: pt(13) } }; // #endregion const aside = { id: 'aside', span: 'side', backgroundEnabled: false, // notes in the margin stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('ember') }, padding: { top: mm(2.2), right: ZERO, bottom: ZERO, left: ZERO }, titleStyle: { fontFamily: MONO, fontSize: pt(7.5), fontWeight: 700, color: col('ember'), textTransform: 'uppercase', letterSpacing: pt(1.2), gap: mm(1.2) }, body: { fontFamily: TEXT, fontSize: pt(8.6), lineHeight: pt(12.5), textAlign: 'left', firstLineIndent: ZERO } }; const colophon = { ...aside, id: 'colophon', stripe: { enabled: false }, body: { ...aside.body, fontFamily: MONO, fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted'), italicColor: col('muted') } }; // #region steps: numbered steps on the grid, a prompt sign for a separator const orderedLists = { fontFamily: DISPLAY, fontWeight: 800, color: col('ember'), gap: em(0.7), separator: '›', separatorGap: em(0.25), separatorFontFamily: MONO, separatorFontWeight: 700, separatorColor: col('muted'), marginTop: ZERO, marginBottom: ZERO }; // the default 1.5 em opens 5.3 mm above and below // #endregion const OUTER = 15; // mm: the outer margin; the running heads align to it const text = (id, content, family, size, look, placement) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('ink'), placement, ...look, align: 'left', overflow: 'wrap' }); // design text is centred and cut with '…' by default const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { x: ZERO, y: mm(y) }, size: { width } }); const opener = { enabled: true, slot: { elements: [ text('kicker', '{attr.kicker}', MONO, 8, { fontWeight: 700, letterSpacing: pt(1.6), textTransform: 'uppercase', color: col('ember') }, { anchor: { to: 'container', edge: 'top-left' }, offset: { x: ZERO, y: mm(4) } }), // Design lineHeights are multiples (gotcha: design-lineheight-multiple). text('title', '{titleText}', DISPLAY, 33, { fontWeight: 800, lineHeight: 1.04 }, below('kicker', 3.5, mm(118))), text('lead', '{attr.lead}', TEXT, 12, { italic: true, lineHeight: 1.36 }, below('title', 5, 'fill')), ] } }; const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: MONO, fontSize: pt(7.5), letterSpacing: pt(1.1), textTransform: 'uppercase', color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...extra, }); const folio = { fontWeight: 700, color: col('ember') }; const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) colorPalette, chipStyles, orderedLists, paragraphStyles: [resume], calloutStyles: [listing, sheet, aside, colophon], // #region page: a text column and a margin column that only listings and notes enter page: { sizePreset: 'custom', width: mm(178), height: mm(229), margins: { top: mm(22), bottom: mm(21), left: mm(20), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'oneAndHalf', sideColumnPercent: 26, gutterWidth: mm(6), sideColumnRole: 'floats', sideColumnSide: 'outer' }, // #endregion bodyText: { ...spacing, // keycaps fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4.5), indentAfterHeading: false, maxRuntTracking: 0, // tracking it cannot paint (gotcha: runt-tracking-unpainted) }, headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 800, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener }, { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: ZERO }, ] }, header: { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('verso-title', '{title}', 'even', 'top-left', OUTER + 8), head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }, footer: { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, { ...folio, pages: 'opener', // the opener has no running head: its folio drops to the foot placement: { anchor: { to: 'container', edge: 'top' }, offset: { x: ZERO, y: mm(9) } } })] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = `---Amostra em Markdown · 118 linhas · content.en.md
title: "The Shell, Gently" subtitle: "A pocket guide to the command line" author: "Tove Ahlberg" --- # Small tools, joined {kicker="Chapter 4" lead="How the pipe character chains programs that each do one job to answer a question about a folder."} Each program in this chapter does one job. \`ls\` lists the names in a folder, \`grep\` keeps the lines that match a pattern, \`sort\` puts lines in order and \`du\` reports how much disk space a file takes. The pipe, the \`|\` character, sends whatever one program prints into the next one, so you can chain them on a single line and read the answer at the end. :::callout{type="aside" title="The prompt"} On a Mac, zsh prints \`%\` instead of \`$\`. ::: Try it in a folder of photographs. In the listings, a line that starts with a dollar sign is one you type, leaving out the dollar, and run with :chip[Enter]{style="key"}. The dollar is the prompt, which the shell prints to show it is waiting for you. The lines under it are the shell’s answer. \`\`\`console Terminal $ cd ~/Pictures/2025 $ ls | grep -c 'JPG$' 268 $ ls | grep -v 'JPG$' IMG_0413.MOV IMG_0977.MOV IMG_1502.PNG $ du -sh *.MOV | sort -rh 812M IMG_0977.MOV 455M IMG_0413.MOV \`\`\` :::paragraphs{style="resume"} \`grep -c\` counts the matching lines instead of printing them, and \`-v\` keeps the lines that do not match. The single quotes hand the pattern to \`grep\` as typed; in it, \`$\` marks the end of the line. The star in the last command belongs to the shell: before \`du\` starts, \`*.MOV\` is already the list of names ending in \`.MOV\`. ::: :::callout{type="aside" title="On a Mac"} :chip[Ctrl]{style="key"} is :chip[control]{style="key"} :chip[Enter]{style="key"} is :chip[return]{style="key"} Shortcuts use :chip[control]{style="key"}, not :chip[command]{style="key"}. ::: ## When a command will not stop Sooner or later you will start a command that does not finish. Type \`grep JPG\` with no file after it, and \`grep\` sits waiting for you to type the lines it should search. To stop it, hold :chip[Ctrl]{style="key"} and press :chip[C]{style="key"}; the prompt comes back and nothing has changed. To end its input properly instead, press :chip[Ctrl]{style="key"} :chip[D]{style="key"} at the start of an empty line; \`grep\` reads it as the end of its input. :chip[Ctrl]{style="key"} :chip[C]{style="key"} also stops a \`ping\`, which would otherwise print a line every second until you close the window. The shell also saves you typing. After the first letters of a file or folder name, press :chip[Tab]{style="key"} and the shell fills in the rest; when more than one name fits, it lists them (bash waits for a second :chip[Tab]{style="key"}). :chip[↑]{style="key"} brings back the last command, and each press goes one further back, so a pipeline with a typo can be mended instead of typed again. 1. Type \`cd ~/Pic\` and press :chip[Tab]{style="key"} to complete the folder name, \`Pictures/\`, then add \`2025\` and press :chip[Enter]{style="key"}. 2. Press :chip[↑]{style="key"} until \`ls | grep -v 'JPG$'\` is back on the line. 3. Hold :chip[Ctrl]{style="key"} and press :chip[A]{style="key"} to jump to the start of the line, then :chip[Ctrl]{style="key"} :chip[E]{style="key"} to return to the end. 4. Type \`| sort -r\` and press :chip[Enter]{style="key"}. The same names come back in reverse order. :::callout{type="sheet" title="Cheat sheet · bash and zsh"} :::columns{count=2 breaks="8"} **On the line** :chip[Ctrl]{style="key"} :chip[A]{style="key"} start of the line :chip[Ctrl]{style="key"} :chip[E]{style="key"} end of the line :chip[Ctrl]{style="key"} :chip[W]{style="key"} cut the word to the left :chip[Ctrl]{style="key"} :chip[K]{style="key"} cut to the end of the line :chip[Ctrl]{style="key"} :chip[Y]{style="key"} paste what you cut :chip[Ctrl]{style="key"} :chip[T]{style="key"} swap two letters **Commands and history** :chip[Ctrl]{style="key"} :chip[R]{style="key"} search earlier commands :chip[Ctrl]{style="key"} :chip[P]{style="key"} the previous command :chip[Ctrl]{style="key"} :chip[C]{style="key"} stop the running command :chip[Ctrl]{style="key"} :chip[Z]{style="key"} pause it; \`fg\` resumes it :chip[Ctrl]{style="key"} :chip[L]{style="key"} clear the screen :chip[Ctrl]{style="key"} :chip[D]{style="key"} close the shell (empty line) ::: ::: ## A script to keep Commands you type every week are worth keeping in a file. The one below copies each folder in Documents to an external disk, into a new folder named after the day’s date. Save it as \`backup.sh\` in your home folder. \`\`\`bash backup.sh #!/usr/bin/env bash # Copy each folder in ~/Documents to a dated folder on the backup disk. set -euo pipefail src="$HOME/Documents" dest="/Volumes/Backup/$(date +%F)" mkdir -p "$dest" for dir in "$src"/*/; do name=$(basename "$dir") rsync -a "$dir" "$dest/$name/" echo "copied $name" done \`\`\` :::callout{type="aside" title="Archive mode"} \`rsync -a\` copies the subfolders too and keeps each file’s dates and permissions. ::: :::paragraphs{style="resume"} The first line, the *shebang*, names the program that runs the file. \`set -euo pipefail\` stops the script at the first command that fails, so it never carries on with half a backup. \`$(date +%F)\` runs \`date\` and puts what it prints, such as 2026-09-26, into the path. The quotes round each variable keep a folder called My Taxes in one piece; without them the shell would split the name at the space and \`rsync\` would look for two folders that do not exist. ::: Run \`chmod +x backup.sh\` once to make the file executable, then start it with \`./backup.sh\`. On Linux an external disk usually appears under \`/media\`, in a folder named after your user, so change the \`dest\` line to match. :::callout{type="colophon"} *The Shell, Gently* is a fictional book written for the Postext Cookbook. Set in Charis SIL, Sora and JetBrains Mono (SIL OFL). Text: original, CC BY 4.0. ::: Chapter 5 points \`grep\` at the log files under \`/var/log\`, where a pipeline of three commands counts how many errors the system logged on each day of the past week.`; // content.<lang>.md, inlined by the Cookbook const source = inlineCode(listings(markdown)); // fences first: their backticks are escaped const continuation = { pageIndexOffset: 48, pageNumbering: { startAt: 49 } }; // p. 49, a recto // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Charis SIL': ['400', '400i'], Sora: ['400', '700', '800'], 'JetBrains Mono': ['400', '400i', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const build = () => buildDocument({ markdown: source, continuation }, config()); const doc = await buildWithFonts(build, markdown); showPages(doc, { title: t({ en: 'The Shell, Gently', es: 'La terminal, con calma' }) });Kit · core, fonts, viewer: igual em todas as receitas · 236 linhas
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Pendurar a aba à direita
A aba passa para o canto superior direito do boxe, que num recto fica sobre a coluna lateral.
- position: 'top-left' },
+ position: 'top-right' },#Dar aos comentários a cor das strings
Sem o chip rem, um comentário é um trecho em itálico, então sai no mesmo verde das strings.
- else if (comment) out += `:chip[${chipText(comment)}]{style="rem"}`;
+ else if (comment) out += `*${escape(comment)}*`;Erros comuns
Erro comum
Um $ solto abre matemática: escreva \$
O cifrão abre matemática em linha, então um preço como $40 inicia uma fórmula. Escreva \$40. Escapes e caracteres literais →
Erro comum
'1998. ' ou '- ' no início de um parágrafo abre uma lista
Um parágrafo que começa com um número, um ponto e um espaço, ou com um hífen e um espaço, vira item de lista. Coloque um word joiner (U+2060) antes do número e escreva os diálogos com travessão. Escapes e caracteres literais →
Erro comum
Um espaço não separável ainda quebra a linha
No postext 1.4.1, o algoritmo de quebra de linha trata U+00A0 como um espaço comum, então 0,08 %, 2,006 s ou seção 2 podem ficar em duas linhas. Junte os dois elementos (0,08%) ou reescreva a frase. Escapes e caracteres literais →
Erro comum
:::columns só funciona dentro de um boxe e nunca se divide
:::columns é ignorado fora de um boxe, e um boxe que se divide nunca corta dentro de um grupo de colunas. O atributo breaks conta blocos filhos, e um boxe aninhado conta como um. Colunas dentro de um boxe →
Erro comum
Um boxe lateral começa na altura do bloco que vem depois do seu delimitador
No postext 1.4.1, um boxe com span: 'side' fica na coluna lateral na altura a que o texto chegou no seu delimitador, na linha seguinte da grade e abaixo dos boxes que já estiverem ali. Abra o delimitador de uma glosa logo antes do parágrafo que ela explica: se vier depois, a glosa começa ao lado do parágrafo seguinte. Um boxe que passaria do pé da coluna sobe até seu pé coincidir com o da coluna, se o boxe de cima permitir; se mesmo assim não couber, espera pela coluna lateral da página seguinte. Notas na margem →
Erro comum
Uma paleta trocada não chega aos elementos de design nem à cor das referências
postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
Só 8 idiomas têm hifenização, com o código exato
A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano. Hifenização e idioma do documento →
Erro comum
O ajuste de linhas curtas pode apertar um tracking que nunca é pintado
No postext 1.4.1, quando um parágrafo termina numa linha curta, a diagramação o compõe com uma linha a menos: primeiro aperta o espaçamento entre palavras, depois aplica até maxRuntTracking milésimos de em de tracking negativo. Os renderizadores de canvas e PDF só pintam tracking acima de zero, então o parágrafo sai impresso sem ele: as linhas justificadas perdem essa diferença nos espaços entre palavras, que ficam esmagados, e a última linha pode passar da medida e ser cortada na borda da coluna. Defina bodyText.maxRuntTracking: 0, que mantém o ajuste pelo espaçamento entre palavras, e reescreva os parágrafos que voltarem a terminar numa linha curta. Viúvas, órfãs e linhas curtas →
Erro comum
O lineHeight de um texto de design é um múltiplo, nunca uma medida
Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
Erro comum
A configuração fica em cache pela identidade: crie um objeto novo
O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →
- Recue as listagens com espaços.
codeLine()transforma os espaços iniciais e as sequências de espaços em espaços não separáveis, enquanto uma tabulação sai como um espaço comum. - Mantenha toda linha de código em até 73 caracteres. Uma linha mais longa quebra num espaço, e um comentário, que é um chip, nunca quebra: ele desce para uma linha só sua e passa da borda do boxe.
- Os escapes de
codeLine()também valem na prosa. Um parágrafo que começa com um ano, como1998. The lab opened, vira o item 1998 de uma lista se não vier antes um word joiner, e\$40impede que um preço vire fórmula. Um diálogo que começa com travessão não precisa de escape; um hífen e um espaço abririam uma lista.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fontes
- Charis SIL (SIL OFL 1.1) · Sora (SIL OFL 1.1) · JetBrains Mono (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


