npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@shieldsbetter/termiflo

v0.1.0

Published

Dependency-free terminal typesetting: word-wrapping with hyphenation, indents, and tables that adapt to terminal width.

Readme

termiflo

CI Coverage

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/termiflo

Use:

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 30

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 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 long

That 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-to for Spanish an-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 (see scripts/build-patterns.mjs for the converter that turns a TeX hyph-*.tex file into a data module); hyphenLeftMin and hyphenRightMin then default to whatever that data declares. Inside a pattern object those two fields keep their conventional names, leftmin and rightmin, 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 width includes its borders
  • minWidth - stubborn minimum width, padding included
  • maxWidth - cap the column width without pinning it, padding included (note that maxWidth is only available in a ColumnSpec and 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' share

Containers 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 TypeError naming the ones it does. cellDefaults is 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, so wide also 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. width and minWidth are 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: a width states it, minWidth raises 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's minWidth behaves.
  • 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.