Client modules
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:
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
- 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.tibifile 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.tsdirectly. - 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.
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:
- The template renders as the host's children, server-side and client-side.
rootin the script is the host element. - Styles are scoped with native CSS nesting under the host tag (
tibi-editor { … });:hostbecomes&.@keyframes/@font-facestay 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. $namerefs only match elements the component owns, never a nested light child's element with the samequid.
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.