Tibi

Client modules

Component and page scripts can import npm packages and project files, use top-level await, and be written in TypeScript. Tibi bundles every import on the site once with esbuild (shared dependencies land in shared chunks) and serves an import map, so the browser loads each module natively.

Setup

Install the packages you import plus esbuild in the project root, next to .tibi-config:

npm i @tiptap/core @tiptap/starter-kit sortablejs npm i -D esbuild

Tibi looks for esbuild in node_modules/.bin (walking up for monorepos), then $TIBI_ESBUILD, then PATH. Projects that never import anything don't need it.

Importing in a component

<template> <ol quid="list"><li>One</li><li>Two</li></ol> </template> <script tibi> import Sortable from 'sortablejs'; import { formatStamp } from '../lib/time.ts'; const sortable = Sortable.create($list, { onEnd: () => emit('reorder') }); onUnmount(() => sortable.destroy()); </script>
  • Default, named ({ a as b }), namespace (* as ns) and side-effect imports all work. Imports must sit at the top level of the script.
  • Bare specifiers resolve from node_modules. Relative specifiers resolve against the .tibi file and are keyed by project path (~/lib/time.ts), so the same file imported from two components is one module. You can also write ~/lib/time.ts directly.
  • URLs (https://esm.sh/…) and absolute paths load as-is and are not bundled.
  • import 'pkg/style.css' attaches the stylesheet to the document before the script continues. CSS a package imports itself is extracted and attached the same way.
  • String-literal dynamic imports (await import('canvas-confetti')) go through the same import map, so they're bundled too.

How it works

Component scripts run inside the custom element's init method, which is a classic script, so the compiler rewrites each import into an awaited window.Tibi.import(key) and marks the init async. That also makes top-level await work in any component or page script. Errors from an async init are logged per component and never block siblings.

tibi serve scans every .tibi file at boot and bundles all keys into .tibi/client/ (served at /__tibi/client/). A page that brings a new key triggers a rebuild; each rebuild is a new generation directory so tabs already open keep working. In dev mode, editing a non-.tibi file (a local .ts module) rebuilds on the next request. tibi build writes the minified bundle to dist/__tibi/client/. Every page gets the import map in <head> plus modulepreload links for the modules it uses.

Plugin helpers that read the current component scope (Tibi.currentScope()) work after the imports resolve. If your own body awaits again, call them before that second await.

TypeScript

Add lang="ts" to the script tag. Types are stripped with esbuild before any other compile step; value imports are kept verbatim so names only used in the template still resolve. There is no type checking at build time: run tsc --noEmit in CI if you want it.

<script tibi lang="ts"> import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; const editor: Editor = new Editor({ element: $surface as HTMLElement, extensions: [StarterKit] }); onUnmount(() => editor.destroy()); </script>

Light DOM components: <template shadow="none">

Components normally render inside a declarative shadow root. Some libraries can't work there: ProseMirror / Tiptap selection handling, tippy and other popover libraries that measure or listen at document level, and global CSS that needs to reach the markup. Opt a component out of shadow DOM on its template tag:

<template shadow="none"> <div class="editor"> <div quid="surface"></div> <slot/> </div> </template> <style> :host { display: block; } .editor { display: flex; flex-direction: column; gap: 0.5rem; } </style>
  • The template renders as the host's children, server-side and client-side. root in the script is the host element.
  • Styles are scoped with native CSS nesting under the host tag (tibi-editor { … }); :host becomes &. @keyframes / @font-face stay top level. The stylesheet ships once per page. Global CSS reaches the markup, which is the point.
  • The default <slot/> becomes a <tibi-slot> element (display: contents) that holds the children passed in. Named slots aren't supported in light mode.
  • $name refs only match elements the component owns, never a nested light child's element with the same quid.

Example

examples/editor-modules in the Tibi repo mounts Tiptap with a < suggestion menu (tippy popover) that inserts timestamp nodes from a local TypeScript module, next to a SortableJS list. Run npm install then tibi serve in that directory.