Skip to content

Todomir/trunky

Repository files navigation

@trunky

Grapheme-accurate rich-text truncation. Clamp multi-line text while preserving inline markup (<strong>, <em>, <code>, custom components) — no CSS line-clamp hacks, no DOM reflow.

"This bold paragraph is long enough to overflow…"
      ────                              ─
      kept                            cut here, marks intact

How it works

  1. Model content as a recursive tree of text, break, and mark nodes
  2. Flatten to grapheme-level tokens (via Intl.Segmenter) preserving the mark stack on each
  3. Measure line count for the full text using @chenglou/pretext (canvas measureText, no DOM layout)
  4. If overflow: binary-search the grapheme cut point that maximizes visible content within the line budget
  5. Rebuild the token slice back into a minimal rich-text tree

Two strategies: end (prefix + ellipsis) and middle (prefix + ellipsis + tail, e.g. file paths).

Packages

@trunky/react  ──►  @trunky/core  ──►  @chenglou/pretext
     │
  react 19 (peer)
Package Description
@trunky/core Framework-agnostic truncation engine. Pure truncate(input) → result with rich-text model, binary-search solver, and pretext-backed measurement cache.
@trunky/react React 19 compound component (Truncate.Root / Content / Toggle). Auto-measures via ResizeObserver + font loading, bridges JSX ↔ RichTextNode, manages expand/collapse state.
playground Vite dev app with resizable demo frames. pnpm run play to launch.

Quick start

import { Truncate } from "@trunky/react";

<Truncate.Root strategy={{ kind: "end" }} clamp={{ maxLines: 2 }}>
  <Truncate.Content>
    This <strong>bold</strong> paragraph is long enough to overflow two lines
    while keeping inline markup intact.
  </Truncate.Content>
  <Truncate.Toggle>
    {(state) => (state.kind === "expanded" ? "Show less" : "Show more")}
  </Truncate.Toggle>
</Truncate.Root>;

Middle truncation (file paths):

<Truncate.Root
  strategy={{ kind: "middle", keepTailGraphemes: 18 }}
  clamp={{ maxLines: 1 }}
>
  <Truncate.Content>
    workspace/design-system/components/navigation/sidebar-entry.tsx
  </Truncate.Content>
</Truncate.Root>

SSR

@trunky/core does not need DOM layout, but its @chenglou/pretext measurement backend still needs Intl.Segmenter plus canvas measurement (OffscreenCanvas or a compatible 2D canvas context). So:

  • @trunky/core can truncate during SSR only in runtimes that provide those primitives
  • plain Node SSR without canvas support cannot measure and should not call truncate() at render time
  • @trunky/react currently renders full content on the server, then measures and truncates after mount on the client

Workspace

trunky/
├── packages/
│   ├── core/           @trunky/core
│   └── react/          @trunky/react
├── apps/
│   └── playground/     dev playground (Vite)
├── tests/              Vitest browser tests (Playwright/Firefox)
├── vite.config.ts      shared Vite + Vitest config
└── pnpm-workspace.yaml

Development

pnpm install
pnpm run play          # launch playground (Vite)
pnpm run test          # Vitest browser tests (headless Firefox via Playwright)
pnpm run build         # build core then react (tsdown)
pnpm run typecheck     # tsc --noEmit across all packages

Key design decisions

  • Grapheme segmentation — cuts at Intl.Segmenter grapheme boundaries, so emoji (👨‍👩‍👧‍👦), combining characters, and complex scripts are never split mid-cluster.
  • Canvas measurement — pretext prepare() + layout() avoids DOM reflow entirely. Expensive preparation is cached globally (~512 LRU entries); line counts are memoized per layouter instance.
  • Mark preservation — the flatten/rebuild cycle carries the full mark stack on every token, so marks around cut points reconstruct correctly without orphaned tags.
  • Framework-agnostic core@trunky/core has no React dependency. The generic Meta type parameter lets any framework map its element model onto the rich-text tree.

About

Truncation utils for JS/React

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors