sveltelean
Versionv0.2.0 GitHub

Two plugins

svelteLean() returns two Vite plugins. svelte-lean:scan runs before the Svelte compiler (enforce: 'pre') and reads the source of every included .svelte file for static data-slean="…" markers. svelte-lean:inject runs after the compiler (enforce: 'post') and appends one import statement per registration module to the compiled JavaScript of that file.

vite.config.ts
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteLean } from '@svelte-lean/vite';
import { defineConfig } from 'vite';

export default defineConfig({
	plugins: [svelteLean(), sveltekit()]
});
+page.svelte, compiled
// What the compiled module of that page receives from the plugin, appended after the
// compiled code (imports are hoisted, so it evaluates first):
import '@svelte-lean/primitives/tabs/register';

The exact format: one import '<specifier>'; per module, single quotes, sorted, de-duplicated, appended after the compiled code and terminated by a newline. Appending keeps every existing line and column in place, so the Svelte compiler's source map stays valid; ES module imports are hoisted, so the registration evaluates before the component code. The source markup is never rewritten.

Bundlers de-duplicate modules: a hundred files that use tabs still produce one registration module in the graph, and registration is idempotent, so a manual import next to an injected one changes nothing. The plugin pair works whether it is placed before or after sveltekit(); put it first so the scanner sees the source before any preprocessor.

Static-only contract

Discovered: data-slean="tabs", data-slean='tabs', data-slean=tabs. Not discovered: an expression or an interpolation. A dynamic marker is recorded in the manifest and reported once per file with its line and column, together with the fix.

Svelte
<!-- Not discovered: the identifier is an expression -->
<div data-slean={kind}>…</div>

<!-- Discovered: a static identifier -->
<div data-slean="tabs">…</div>
Build warning
Dynamic behavior identifiers cannot be auto-discovered.
Found:
  src/lib/Panel.svelte:12:2  data-slean={kind}
Use a static identifier such as data-slean="tabs", or import the registration module
manually (for example import '@svelte-lean/primitives/tabs/register').

The scanner is lexical and does not use the Svelte compiler. It skips <script> and <style> content, HTML comments, quoted attribute values and {…} expressions in text (including strings, templates, regular expressions and comments inside them), so a marker inside a string literal is not a false positive. Markers inside {#if} and {#each} blocks are found like any other markup. It does not see markup produced at runtime through {@html …}, and it does not scan node_modules by default: a library that renders Svelte Lean markup either is added to include or imports its registration modules itself.

Manifest

A production build writes a manifest: every behavior found, the module injected for it (null for native and unknown behaviors), the files that use it, the dynamic markers and the unknown names. It is a build artifact for diagnostics and tooling and is not shipped to the browser.

node_modules/.svelte-lean/manifest.json
{
	"version": 1,
	"behaviors": {
		"button": { "files": ["src/lib/Toolbar.svelte"], "module": null },
		"tabs": {
			"files": ["src/routes/settings/+page.svelte"],
			"module": "@svelte-lean/primitives/tabs/register"
		}
	},
	"dynamic": {
		"src/lib/Panel.svelte": [{ "line": 12, "expression": "{kind}" }]
	},
	"unknown": []
}

The manifest of the build you are reading, read at prerender time from node_modules/.svelte-lean/manifest.json:

BehaviorRegistration moduleFiles
accordionnone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/accordion/+page.svelte
alertnone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/alert/+page.svelte
alert-dialognone (native)src/routes/primitives/alert-dialog/+page.svelte
autocompletenone (native)src/routes/primitives/autocomplete/+page.svelte
avatarnone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/avatar/+page.svelte
badgenone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/badge/+page.svelte
breadcrumbnone (native)src/routes/primitives/breadcrumb/+page.svelte
buttonnone (native)src/lib/components/CopyButton.svelte, src/lib/components/ThemeToggle.svelte, src/lib/components/TopNav.svelte, src/lib/home/ListenerLab.svelte, src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/docs/architecture/native-first/+page.svelte, src/routes/performance/results/+page.svelte, src/routes/performance/results/RowsSpecimen.svelte, src/routes/primitives/alert-dialog/+page.svelte, src/routes/primitives/alert/+page.svelte, src/routes/primitives/badge/+page.svelte, src/routes/primitives/button-group/+page.svelte, src/routes/primitives/button/+page.svelte, src/routes/primitives/card/+page.svelte, src/routes/primitives/combobox/+page.svelte, src/routes/primitives/dialog/+page.svelte, src/routes/primitives/drawer/+page.svelte, src/routes/primitives/empty/+page.svelte, src/routes/primitives/kbd/+page.svelte, src/routes/primitives/listbox/+page.svelte, src/routes/primitives/menu/+page.svelte, src/routes/primitives/popover/+page.svelte, src/routes/primitives/spinner/+page.svelte, src/routes/primitives/toast/+page.svelte, src/routes/primitives/toolbar/+page.svelte, src/routes/primitives/tooltip/+page.svelte, src/routes/proof/+page.svelte, src/routes/styling/themes/+page.svelte, src/routes/styling/variants/+page.svelte, src/routes/table/clipboard-export/CsvExport.svelte, src/routes/table/clipboard-export/RangeClipboard.svelte, src/routes/table/columns/Visibility.svelte, src/routes/table/editing/Editing.svelte, src/routes/table/pagination/Pagination.svelte, src/routes/table/rows/NestedTable.svelte, src/routes/table/rows/RowAnimation.svelte, src/routes/table/rows/RowEvents.svelte, src/routes/table/selection/Selection.svelte, src/routes/table/server-data/EmptyStates.svelte, src/routes/table/server-data/LoadingEmpty.svelte, src/routes/table/server-data/ServerData.svelte, src/routes/table/state-persistence/PersistedState.svelte
button-groupnone (native)src/routes/primitives/button-group/+page.svelte
calendar@svelte-lean/primitives/calendar/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/calendar/+page.svelte, src/routes/primitives/date-picker/+page.svelte
cardnone (native)src/routes/primitives/card/+page.svelte
carouselnone (native)src/routes/primitives/carousel/+page.svelte
checkboxnone (native)/vercel/path0/packages/table/dist/table/data-table.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/primitives/checkbox/+page.svelte, src/routes/primitives/dialog/+page.svelte, src/routes/primitives/drawer/+page.svelte, src/routes/proof/+page.svelte, src/routes/table/layout/DensityBorders.svelte, src/routes/table/server-data/LoadingEmpty.svelte
color-fieldnone (native)src/routes/primitives/color-field/+page.svelte
combobox@svelte-lean/primitives/combobox/registersrc/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/docs/architecture/runtime-tiers/+page.svelte, src/routes/primitives/combobox/+page.svelte, src/routes/proof/+page.svelte
context-menu@svelte-lean/primitives/context-menu/registersrc/routes/primitives/context-menu/+page.svelte
date-fieldnone (native)src/routes/primitives/date-field/+page.svelte
date-picker@svelte-lean/primitives/date-picker/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/date-picker/+page.svelte
descriptionsnone (native)src/routes/primitives/descriptions/+page.svelte
dialognone (native)src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/docs/architecture/native-first/+page.svelte, src/routes/primitives/combobox/+page.svelte, src/routes/primitives/dialog/+page.svelte, src/routes/proof/+page.svelte, src/routes/styling/variants/+page.svelte
disclosurenone (native)src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/primitives/disclosure/+page.svelte, src/routes/proof/+page.svelte
drawernone (native)src/routes/primitives/drawer/+page.svelte
emptynone (native)src/routes/primitives/empty/+page.svelte
fieldnone (native)src/routes/primitives/field/+page.svelte, src/routes/primitives/input/+page.svelte
file-drop@svelte-lean/primitives/file-drop/registersrc/routes/primitives/file-drop/+page.svelte
file-fieldnone (native)src/routes/primitives/file-field/+page.svelte
hover-card@svelte-lean/primitives/hover-card/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/hover-card/+page.svelte
inputnone (native)src/routes/primitives/field/+page.svelte, src/routes/primitives/input/+page.svelte, src/routes/styling/themes/+page.svelte, src/routes/table/server-data/EmptyStates.svelte
kbdnone (native)src/routes/primitives/kbd/+page.svelte
listbox@svelte-lean/primitives/listbox/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/listbox/+page.svelte, src/routes/proof/+page.svelte
menu@svelte-lean/primitives/menu/registersrc/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/primitives/button-group/+page.svelte, src/routes/primitives/context-menu/+page.svelte, src/routes/primitives/menu/+page.svelte, src/routes/primitives/menubar/+page.svelte, src/routes/proof/+page.svelte, src/routes/table/columns/Visibility.svelte
menubar@svelte-lean/primitives/menubar/registersrc/routes/primitives/menubar/+page.svelte
meternone (native)src/routes/primitives/meter/+page.svelte
number-field@svelte-lean/primitives/number-field/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/number-field/+page.svelte
otp-fieldnone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/otp-field/+page.svelte
paginationnone (native)src/routes/primitives/pagination/+page.svelte
popovernone (native)src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/docs/architecture/native-first/+page.svelte, src/routes/primitives/listbox/+page.svelte, src/routes/primitives/popover/+page.svelte, src/routes/proof/+page.svelte, src/routes/styling/variants/+page.svelte, src/routes/table/sorting/HeaderSort.svelte
progressnone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/progress/+page.svelte
radio-groupnone (native)src/lib/home/TableLab.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/primitives/radio-group/+page.svelte, src/routes/proof/+page.svelte, src/routes/table/layout/DensityBorders.svelte, src/routes/table/localization-rtl/Messages.svelte, src/routes/table/selection/Selection.svelte
range-slider@svelte-lean/primitives/range-slider/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/range-slider/+page.svelte
ratingnone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/rating/+page.svelte
scroll-areanone (native)src/routes/primitives/scroll-area/+page.svelte
segmentednone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/field/+page.svelte, src/routes/primitives/segmented/+page.svelte
select@svelte-lean/primitives/select/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/select/+page.svelte
separatornone (native)src/routes/primitives/separator/+page.svelte
skeletonnone (native)src/routes/primitives/skeleton/+page.svelte
slidernone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/slider/+page.svelte
spinnernone (native)src/routes/primitives/skeleton/+page.svelte, src/routes/primitives/spinner/+page.svelte
splitter@svelte-lean/primitives/splitter/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/splitter/+page.svelte
stepsnone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/steps/+page.svelte
switchnone (native)src/lib/home/Stage.svelte, src/lib/home/TableLab.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/primitives/switch/+page.svelte, src/routes/proof/+page.svelte
tabs@svelte-lean/primitives/tabs/registersrc/lib/components/ExampleFrame.svelte, src/lib/components/InstallBlock.svelte, src/lib/components/TabsRoots.svelte, src/lib/home/Compare.svelte, src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/docs/architecture/dom-protocol/+page.svelte, src/routes/docs/architecture/runtime-tiers/+page.svelte, src/routes/primitives/tabs/+page.svelte, src/routes/proof/+page.svelte, src/routes/styling/variants/+page.svelte
tagnone (native)src/lib/primitives/Gallery.svelte, src/routes/primitives/tag/+page.svelte
timelinenone (native)src/routes/primitives/timeline/+page.svelte
toast@svelte-lean/primitives/toast/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/toast/+page.svelte
toggle@svelte-lean/primitives/toggle/registersrc/routes/primitives/toggle/+page.svelte, src/routes/primitives/toolbar/+page.svelte
toggle-group@svelte-lean/primitives/toggle-group/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/toggle-group/+page.svelte, src/routes/primitives/toolbar/+page.svelte
toolbar@svelte-lean/primitives/toolbar/registersrc/routes/primitives/toolbar/+page.svelte
tooltip@svelte-lean/primitives/tooltip/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/tooltip/+page.svelte
tree@svelte-lean/primitives/tree/registersrc/lib/primitives/Gallery.svelte, src/routes/primitives/tree/+page.svelte

Build report

With report: true the plugin prints what the scan observed after the client bundle is written. It states no size and no listener count: those are measured by the consumer fixtures against real production output, not estimated by the plugin.

Build output
svelte-lean build report
  native-only  button, dialog
  runtime      tabs -> @svelte-lean/primitives/tabs/register
                 src/routes/settings/+page.svelte
  unknown      none
  dynamic      none
  manifest     node_modules/.svelte-lean/manifest.json

Options

vite.config.ts
svelteLean({
	include: ['**/*.svelte'],
	exclude: ['**/node_modules/**'],
	package: '@svelte-lean/primitives',
	behaviors: {},
	manifest: true,
	report: false,
	warnDynamic: true,
	ssr: 'skip'
});
NameTypeDefaultDescription
# include string | RegExp | (string | RegExp)[]['**/*.svelte']Files to scan. Globs match the path relative to the Vite root and the absolute path; style and ?raw sub-requests are never touched.
# exclude string | RegExp | (string | RegExp)[]['**/node_modules/**']Files never scanned or injected into. Tested after include.
# package string'@svelte-lean/primitives'Re-bases the built-in registration specifiers onto another package that provides the same modules.
# behaviors Record<string, string | false>{}Overrides and extends the built-in table: a module specifier to inject, or false for a behavior that needs no runtime.
# manifest boolean | stringtrueWrite node_modules/.svelte-lean/manifest.json (or the given path) at the end of a production build. Not written in dev or after a failed build.
# report booleanfalsePrint the build report after the client bundle is written; SSR builds are silent.
# warnDynamic booleantrueWarn once per file about data-slean={expression} markers, through the plugin context so the warning carries file and position.
# ssr 'skip' | 'inject''skip'Whether server modules also receive the imports. Registration modules are SSR-safe, so this is a server bundle size choice only.

The built-in table maps every Tier 1 and Tier 2 primitive to its register module and every Tier 0 primitive (plus the radio vocabulary of ADR 0001) to no runtime. A test in the primitives package keeps this table equal to the package's own metadata, so a new Tier 1 primitive cannot exist without a registration module here. A name outside the table is reported once and injects nothing.

The shared router

What the injected module does is small. registerBehavior() in @svelte-lean/core stores the definition and installs at most one listener per event type the definition declares, on the document runtime. Only event types some registered behavior uses are installed; pointer-move and scroll are never installed generically. Routing walks event.composedPath() from the target outward and looks at the data-slean attribute of each element on the way, so nested behaviors work and no selector runs over the document.

TypeScript
// @svelte-lean/core, in outline: one listener per event type a registered behavior declares.
// Routing walks composedPath() from the target outward and looks at dataset.slean on the way;
// no selector runs over the document, and a root inserted later is found on its first event.
registerBehavior('tabs', {
	tier: 1,
	events: {
		click({ root, target, event }) { … },
		keydown({ root, target, event }) { … }
	}
});

Discovery is lazy through interaction: a root inserted after registration is found on its first event, so a MutationObserver over the document is not needed and is not used. The observer would do work on every unrelated DOM update and create controllers for instances nobody touches; both are what the tier model exists to avoid. Registration is idempotent and safe to evaluate more than once, and installation waits until a browser environment exists, so the same module imports under Node.

TypeScript
import { createRuntime } from '@svelte-lean/core';
import { tabs } from '@svelte-lean/primitives/tabs';

// A second runtime for a shadow root or an iframe document; the default one is the document.
const runtime = createRuntime({ root: shadowRoot });
runtime.register('tabs', tabs);

The listener count is a release gate: 1000 tabs roots on one page keep one click and one keydown listener, counted by the runtime and by an independent wrapper around addEventListener (listeners.spec.ts). The proof page repeats the reading in your browser.

Measured

The production fixtures in the repository assert the module graph the plugin produces. The tabs-only fixture ships a Svelte Lean chunk of 1521 B brotli; the manual-registration fixture, which imports the register module by hand without the plugin, ships 1521 B brotli with the same module set asserted; tabs and menu together with one kernel ship 2351 B brotli. The fixture that uses only native markup ships no chunk at all.

Source