@shieldsbetter/termiflo
v0.1.0
Published
Dependency-free terminal typesetting: word-wrapping with hyphenation, indents, and tables that adapt to terminal width.
Maintainers
Readme
termiflo
Dependency-free terminal typesetting for non-interactive output. Features:
- Word-wrapping with language-aware hyphenation
- Indents and hanging indents
- Tables with fixed or dynamic column widths
- Vertical stacking with collapsing margins
- ANSI/CJK/emoji aware
Zero dependencies. Fully composable.
Example
import { text, stack, table } from '@shieldsbetter/termiflo';
console.log(
table(
[
[
'What',
stack([
text(
'Sometimes its nice to have your terminal text laid ' +
'out very nicely no matter what the column width.',
{ align: 'justify', hyphenate: true, marginBottom: 1 },
),
text("Don't you think?", {
align: 'right',
marginTop: 2,
marginBottom: 1,
}),
]),
],
[
'How',
table(
[
['Just', 'build', 'your'],
['tree', 'and', 'go'],
],
{
align: 'center',
cellDefaults: {
align: 'right',
},
columns: [{}, {}, { minWidth: 20 }],
},
),
],
],
{
head: ['Key', 'Demo'],
border: 'round',
rowLines: true,
},
).toString(56),
);Output:
╭──────┬───────────────────────────────────────────────╮
│ Key │ Demo │
├──────┼───────────────────────────────────────────────┤
│ What │ Sometimes its nice to have your terminal text │
│ │ laid out very nicely no matter what the col- │
│ │ umn width. │
│ │ │
│ │ │
│ │ Don't you think? │
│ │ │
├──────┼───────────────────────────────────────────────┤
│ How │ ┌──────┬───────┬────────────────────┐ │
│ │ │ Just │ build │ your │ │
│ │ │ tree │ and │ go │ │
│ │ └──────┴───────┴────────────────────┘ │
╰──────┴───────────────────────────────────────────────╯Install
No dependencies. Node ≥ 18. ESM only. TypeScript declarations are bundled; there is nothing to install from DefinitelyTyped.
npm install @shieldsbetter/termifloUse:
import { text, table, stack, stringWidth } from '@shieldsbetter/termiflo';Layout - text(content, options?) : Text
Flow text into the available space.
console.log(text('Typesetting in the terminal should look good.').toString(24));
// Typesetting in the ter-
// minal should look good.width and minWidth are outer boundaries. Indent and padding come out of that budget.
text(prose, { width: 30, paddingLeft: 2, hangingIndent: 4 });
// every line sits 2 in, continuation lines 6 in, nothing past column 30Options
| option | type | default | meaning |
| ---------------------------------- | -------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| width | number \| string \| "auto" | "auto" | Desired width, in columns or as a string expressing percentage of the available width. auto gives the default behavior for the layout type. For text(), that's "100%". |
| paddingLeft | number \| string | 0 | Number of spaces to pad each line, or a literal string to use. |
| paddingRight | number \| string | 0 | Number of spaces to pad the right of each line, or a literal string to use. A literal string is padded out to meet, so the block reads flush rather than ragged. |
| indent | number \| string | 0 | Extra indent on the first line, in addition to paddingLeft. As a space count or a literal string. |
| hangingIndent | number \| string | 0 | Extra indent on continuation lines, in addition to paddingLeft. As a space count or a literal string. |
| minWidth | number \| string | 0 | Stubborn minimum width. Honored unless all blocks competing for available space are already at their minimum widths. Note that only a concrete number influences layout--a percentage defers to the parent's whims. Percentage can, however, be used to provide a floor on width (e.g.: width=5, minWidth="10%" means "5 columns or 10% of available space, whichever is larger") |
| align | "left" \| "right" \| "center" \| "justify" | "left" | How text is laid out. |
| hyphenate | boolean | true | Break long words at hyphenation points rather than mid-letter (en-US). |
| hyphenChar | string | "-" | Inserted at a hyphenation break. |
| hyphenation | pattern data | en-US | Swap in another language's patterns. |
| hyphenLeftMin / hyphenRightMin | number | 2 / 3 | Minimum letters kept on each side of a hyphen break. Overrides what the pattern data declares. |
| ambiguous | "narrow" \| "wide" | inherited | Columns to charge East Asian Ambiguous characters. Inherited by everything nested inside; "narrow" at the root. |
| marginTop / marginBottom | number | 0 | Minimum blank lines above and below. |
Indents & padding
// Hanging indent (bibliography / definition style)
text(prose, { hangingIndent: 4 });
// First-line indent (prose paragraph)
text(prose, { indent: 4 });
// Left padding on every line, still within `width`
text(prose, { width: 60, paddingLeft: 4 });
// Padding on both sides: a narrower measure inside the same width
text(prose, { width: 60, paddingLeft: 4, paddingRight: 4 });
// Padding + hanging indent, composed (no manual arithmetic)
text(prose, { width: 60, paddingLeft: 2, hangingIndent: 4 });
// A quote-bar gutter
text(prose, { paddingLeft: '> ' });
// Gutters on both sides. A right-hand gutter only lines up if what precedes it
// is padded out, so asking for one makes the block flush instead of ragged.
text(prose, { width: 40, paddingLeft: '> ', paddingRight: ' |' });Hyphenation
On by default, using the bundled en-US patterns. A word that doesn't fit
is split at a linguistically legal point, with hyphenChar (default -)
inserted at the break and counted against the width. hyphenate: false turns it
off, and the word is then broken wherever the line runs out:
text('extraordinarily long', { width: 12 });
// extraordi-
// narily long
text('extraordinarily long', { width: 12, hyphenate: false });
// extraordinar
// ily longThat second form is also the fallback whenever hyphenation finds nothing usable: a word too long for the whole field is always broken rather than allowed to overflow.
The default assumes English. Only en-US patterns ship, and any run of letters is eligible, so other languages are hyphenated with English rules: sometimes correctly (
Freund-schaft), sometimes not (an-teproyec-tofor Spanishan-te-pro-yec-to), and sometimes not at all, falling back to a hard break where no pattern matches.To typeset another language, pass a compatible pattern object as
hyphenation(seescripts/build-patterns.mjsfor the converter that turns a TeXhyph-*.texfile into a data module);hyphenLeftMinandhyphenRightMinthen default to whatever that data declares. Inside a pattern object those two fields keep their conventional names,leftminandrightmin, so a module derived from hyph-utf8 (or a hypher-style pattern object) can be handed over unchanged.
Breaks come from the Knuth–Liang algorithm with the en-US TeX patterns, the
same approach LaTeX uses. Each pattern carries digit weights between letters;
every pattern matching a word is overlaid, the highest weight at each gap wins,
and an odd weight means a break is legal. An exceptions list overrides the
patterns for words they get wrong, and hyphenLeftMin / hyphenRightMin (2 and
3 for en-US) keep a minimum number of letters on each side of any break.
Only pure-letter words are eligible. A token containing digits or punctuation
is treated as unbreakable (super-cali-fragilistic is one token, not three)
and is hard-broken at whatever column the line runs out at, with no hyphen.
When it actually fires
Line breaking is greedy, not TeX's whole-paragraph optimizer. Hyphenation is attempted only at the moment a word doesn't fit, and only succeeds if a legal break fits in the space that is genuinely left on the line. At a comfortable width it therefore changes nothing at all. The point where it starts earning its keep is where the gaps get ugly:
const prose =
'Typesetting in the terminal should produce beautifully wrapped ' +
'paragraphs that respect the available width, hyphenating long words.';
text(prose, { width: 46, align: 'justify' });
// identical with or without hyphenation: no word ever overflows a line
text(prose, { width: 30, align: 'justify' });
// hyphenate: false hyphenate: true (default)
// ================ =========================
// Typesetting in the terminal Typesetting in the terminal
// should produce beautifully should produce beautifully
// wrapped paragraphs that wrapped paragraphs that re-
// respect the available width, spect the available width, hy-
// hyphenating long words. phenating long words.Layout - table(rows, options?) : Table
rows is an array of rows. The table will have as many columns as the longest
row.
Each row is an array of cell values. Each call value may be a Block (see
below), a Cell (from cell()), null/undefined which will be rendered
empty, or any other value, which is stringified into a Text block.
table(
[
[
'Alice',
'Engineering',
'Builds the typesetting library and its tooling.',
],
['Bob', 'Design', 'Pixels, mostly.'],
],
{ head: ['Name', 'Team', 'Notes'], width: 50, border: 'round' },
);
// ╭───────┬─────────────┬──────────────────────────╮
// │ Name │ Team │ Notes │
// ├───────┼─────────────┼──────────────────────────┤
// │ Alice │ Engineering │ Builds the typesetting │
// │ │ │ library and its tooling. │
// │ Bob │ Design │ Pixels, mostly. │
// ╰───────┴─────────────┴──────────────────────────╯Table options
| option | type | default | meaning |
| ---------------------------- | ------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| width | number \| string \| "auto" | "auto" | Desired total width, borders included, as a number of terminal columns or as a string expressing percentage of the available width. auto gives the default behavior for the layout type. For table(), that's the table's natural width. |
| minWidth | number \| string | 0 | Stubborn minimum width, borders included. Honored unless all blocks competing for available space are already at their minimum widths. Note that only a concrete number influences layout--a percentage defers to the parent's whims. Percentage can, however, be used to provide a floor on width (e.g.: width=5, minWidth="10%" means "5 columns or 10% of available space, whichever is larger") |
| head | string[] | none | Header row. |
| columns | ColumnSpec[] | none | Per-column configuration (see below). |
| align | "left" \| "right" \| "center" | "left" | How the drawn table is laid out in its available space. Sizing is width's job (see below). |
| valign | "top" \| "middle" \| "bottom" | "top" | Vertical alignment of cell contents. Override per-column with columns[].valign or per-cell with cell() |
| cellDefaults | object | none | Block options handed to whatever sits in each cell: align, hyphenate, paddingLeft, marginBottom, anything a block takes (see below). |
| border | string \| object | "single" | none, space, ascii, single, round, double, heavy, markdown, or a custom glyph object. |
| rowLines | boolean | false | Draw a rule between every body row. |
| minRowHeight | number | none | Least height, in lines, for every body row. Short rows are padded out to it; a taller row keeps its height, since trimming would discard content. |
| paddingLeft | number | border default | Blank columns inside each cell, left of its content. |
| paddingRight | number | border default | Blank columns inside each cell, right of its content. Each side falls back to the border's own padding on its own, so setting one never implies the other. |
| ambiguous | "narrow" \| "wide" | inherited | Columns to charge East Asian Ambiguous characters, the border glyphs included. Inherited by everything nested inside; "narrow" at the root. |
| marginTop / marginBottom | number | 0 | Minimum blank lines above and below. |
Column specs
Column properties can be set in the columns property of the table options,
which should be formatted as an array of ColumnSpecs where the ith entry
specifies options for the ith column of the table.
For example,
columns: [
{ width: 3 }, // fixed width
{ maxWidth: 20 }, // flexible, capped
{ valign: 'middle' }, // where these cells sit in their row
{ cellDefaults: { align: 'right' } }, // options for this column's cells
];A ColumnSpec can have the following fields:
- header - a string to display as the column header
- width - desired width of the column, its cell padding included, the same
way the table's own
widthincludes its borders - minWidth - stubborn minimum width, padding included
- maxWidth - cap the column width without pinning it, padding included (note
that
maxWidthis only available in aColumnSpecand not in other width contexts) - valign - vertical position of content within a cell (override per-cell with
cell()) - cellDefaults - an object of properties to apply to cell-content Blocks (e.g.,
align,hyphenate)
A table's cellDefaults applies to every column; a fields in a column's cellDefaults override table-scoped fields and applies only to the column. Options passed directly to the individual cell value override both. Example:
table([
[
'a', // { align: 'center' } (from column cellDefaults)
'b' // { align: 'right'} (from table cellDefaults)
],
[
text('c', { align: 'left' }) // { align: 'left' }
text('d', { align: 'left' }) // { align: 'left' }
]
], {
cellDefaults: { align: 'right' },
columns: [
{ cellDefaults: { align: 'center' } }
]
})Sizing
If a column does not specify a width, it will try to take the maximum natural width over all its cell contents. If it specifies a width, it will try to take that instead.
If the table cannot accommodate all the columns at those widths within its own width constraints, it will proportionally resize its columns subject to their minWidth and maxWidth values.
If the table does not have enough available width even when each column has been reduced to its associated minWidth, it will continue proportionally resizing them past their minWidth until it can honor its own width constraints.
If a table cannot meet its required width or minWidth even with every column at its maxWidth, it will pad itself on the left and right with spaces, subject to its align option.
Layout - stack(blocks, options?) : Stack
stack composes Blocks vertically with collapsing margins. The gap between two stacked Blocks is the larger of the upper's bottom margin and the lower's top margin.
false, null, and undefined children are dropped (handy for conditional
sections: stack([title, showBody && body])). Other falsy values are not: 0
and '' are stringified and stacked like any other value.
stack([
text('Heading', { marginBottom: 1 }),
shouldDisplay && text('Hello!', { marginBottom: 1 }),
table(rows, { marginTop: 1, marginBottom: 1 }),
]);Stack options
| option | type | default | meaning |
| ---------------------------- | ---------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| width | number \| string \| "auto" | "auto" | Desired width, in columns or as a string expressing percentage of the available width. auto gives the default behavior for the layout type. For stack(), that's "100%". |
| minWidth | number \| string | 0 | Stubborn minimum width. Honored unless all blocks competing for available space are already at their minimum widths. Note that only a concrete number influences layout--a percentage defers to the parent's whims. Percentage can, however, be used to provide a floor on width (e.g.: width=5, minWidth="10%" means "5 columns or 10% of available space, whichever is larger") |
| minSpacing | number | 0 | Minimum spacing between neighboring child Blocks. Applies only between children, never at the stack's outer edges. Final spacing is thus Math.max(child[n].marginBottom, child[n + 1].marginTop, minSpacing). |
| marginTop / marginBottom | number | first / last child's | Minimum blank lines above and below. Unlike the other layouts these default to the outermost children's own margins, so a collapse carries out through a nested stack. |
| ambiguous | "narrow" \| "wide" | inherited | Columns to charge East Asian Ambiguous characters. Inherited by everything nested inside; "narrow" at the root. |
Percentage widths
width, minWidth, and ColumnSpec's maxWidth accept percentage strings in addition to terminal column counts. A percentage is always a percentage of the block's available
width. At the root this is the display width passed to the toString(displayWidth) method (default: terminal width), in nested contexts it is the available content area of the parent.
text(prose, { width: '50%' }); // half of whatever contains it
text(prose, { width: '75%' }); // three quarters of whatever contains it
table(rows, { columns: [{ width: '25%' }, {}] }); // a quarter of the columns' shareContainers size themselves normally and then child percentages are applied to
the space available to them. A percentage never affects what a block asks its
container for: a terminal-column-count width or minWidth does, but a share
of an unknown width cannot.
Adding a percentage width to table cell contents expresses a percentage of the width of the column, not the table. A percentage on a column spec expresses a percentage of the space the columns divide between them: the table's width less its rules, since a column's width includes its own padding.
width defaults to "auto" on all three--the
block then does whatever it does by default. A Text or Stack always lays out
in the space it has, so for those "auto" and "100%" are the same thing. A
Table sizes to its content, so there they differ: "100%" thus becomes an affirmative
instruction to fill.
A width string that isn't a percentage is an error rather than a silent no-op:
text(prose, { width: '80px' }); // TypeError: invalid width "80px"Ambiguous-width characters
Strictly speaking, some characters cannot be correctly aligned without knowing
how the terminal is configured to handle so-called Ambiguous Width characters
(which may consume 1 or 2 terminal columns). Resolving the ambiguity would
require probing the terminal, but termiflo chooses to operate at the string
layer to maximize flexibility--you need not display output strings in a
terminal.
Almost always, modern terminals are configured to resolve this ambiguity toward
1-column output, so termiflo encodes this as the default. If you find that
this causes output (and particularly tables using box-drawing-characters for
borders) to be misaligned, you can change the default by passing
{ ambiguous: 'wide' } as an option to the root text(), table(), or
stack().
Notes & limits
- Every option bag is closed: an option a factory does not have is a
TypeErrornaming the ones it does.cellDefaultsis the exception, since it carries options for whatever Block sits in the cell, which may be a subclass of your own. - Hyphenation applies to pure-letter words; tokens containing punctuation or digits are treated as unbreakable (then hard-broken if they exceed the line). ANSI escapes are stripped before that test, so a colored word breaks exactly where the same word unstyled would.
- Measurement and slicing work in grapheme clusters, not code points, so a skin-tone emoji or a ZWJ sequence counts as the single glyph it draws as: two columns, not the four or six its code points would add up to. A line break never lands inside one. Combining marks ride along at zero width.
- Ambiguous-width characters are treated as narrow by default. Unicode
marks a set of characters (arrows, some punctuation, and all box drawing) as
East Asian Width = Ambiguous, leaving the terminal to render them one column
or two. termiflo assumes one unless told otherwise by
ambiguous: 'wide'; see Ambiguous-width characters. The Private Use Area is in that set, sowidealso widens Nerd Font and Powerline glyphs, which the fonts supplying them usually draw single-width. - The width model describes what a terminal does, which is not always what your font draws. If a font lacks the ligature for a ZWJ sequence it renders as several glyphs and the terminal's own accounting will disagree with itself, which no library can correct for.
- Color continuation tracks SGR (
\x1b[…m) codes; OSC hyperlinks are preserved inline but not reopened across a wrap. - A width narrower than 2 columns, an indent with no room behind it, and a word longer than the available width are all resolved in favor of the layout rather than the request: every rendered block is an exact rectangle, and no single option outranks that.
- Neither width option can exceed the available width.
widthandminWidthare both requests within it, and content is wrapped or broken to fit rather than allowed to stick out. Both reach the container the same way, by shaping what the block asks for: awidthstates it,minWidthraises it. So a container that can afford what you asked for sizes itself around it, and one that cannot is not broken by it, the same way a column spec'sminWidthbehaves. - Only two floors are irreducible, and a space too small for them is overflowed: 2 columns for text, and its frame for a table (borders, separators, padding, and 2 columns per column). When that happens the container grows around it, so the result is still a rectangle.
License
ISC. The bundled en-US hyphenation patterns are from the hyph-utf8 project and
are not covered by it: they carry their own permissive notice, which requires
only that the copyright and the notice itself be preserved. Both are retained
in full at the top of src/data/hyphenation-en-us.js, and summarized in
LICENSE.
If you re-publish termiflo inside a bundle, note that minifiers strip comments,
which would drop that notice. Configure yours to preserve the header comment in
the data file (most support a /*!-style or per-file exclusion), or reproduce
the notice in your own third-party attributions.
