# Build-time discovery

> How @svelte-lean/vite scans static markers, injects register modules, writes the manifest and build report, and why there is no MutationObserver.

Page: https://svelte-lean.vercel.app/docs/architecture/build-time-discovery · Markdown: https://svelte-lean.vercel.app/docs/architecture/build-time-discovery.md · Index: https://svelte-lean.vercel.app/llms.txt

The build finds static markers in your markup and imports the matching registration modules; the runtime finds each root on its first event.

## Two plugins

- `svelte-lean:scan` (`enforce: 'pre'`) reads each included `.svelte` source for static `data-slean="…"` markers.
- `svelte-lean:inject` (`enforce: 'post'`) appends one import per registration module to the compiled file.

`vite.config.ts`

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

```js
// 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';
```

- Imports are sorted, de-duplicated and appended, so source maps stay valid; hoisting runs them before the component code. The source markup is never rewritten.
- A hundred files with tabs still yield one module in the graph; registration is idempotent, so a manual import next to an injected one changes nothing.
- Works 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`. An expression is not; it is recorded in the manifest and reported once per file with its position and 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`

```bash
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: it skips `<script>`, `<style>`, comments, quoted values and `{…}` expressions, so a marker in a string is no false positive.
- Markers inside `{#if}` and `{#each}` are found.
- Not seen: markup from `{@html …}`, and `node_modules` by default. A library goes in `include` or imports its registration modules itself.

## Manifest

Each production build writes it for diagnostics and tooling; it is never shipped to the browser.

`node_modules/.svelte-lean/manifest.json`

```js
{
	"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": []
}
```

This site's own manifest, read at prerender time from `node_modules/.svelte-lean/manifest.json`:

| Behavior | Registration module | Files |
| --- | --- | --- |
| accordion | none (native) | src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/primitives/accordion/+page.svelte, src/routes/proof/+page.svelte |
| alert | none (native) | src/lib/home/Showcase.svelte, src/lib/primitives/Gallery.svelte, src/routes/primitives/alert/+page.svelte, src/routes/styling/variants/+page.svelte |
| autocomplete | @svelte-lean/primitives/autocomplete/register | src/lib/primitives/Gallery.svelte, src/routes/docs/architecture/runtime-tiers/+page.svelte, src/routes/primitives/autocomplete/examples/Autoselect.svelte, src/routes/primitives/autocomplete/examples/City.svelte, src/routes/primitives/autocomplete/examples/Looks.svelte, src/routes/primitives/autocomplete/examples/Palette.svelte, src/routes/primitives/autocomplete/examples/Remote.svelte, src/routes/primitives/autocomplete/examples/Rich.svelte |
| avatar | none (native) | src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/primitives/avatar/+page.svelte, src/routes/primitives/card/+page.svelte, src/routes/primitives/hover-card/+page.svelte, src/routes/primitives/listbox/+page.svelte, src/routes/primitives/menu/+page.svelte, src/routes/styling/variants/+page.svelte |
| badge | none (native) | src/lib/home/Showcase.svelte, src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/primitives/badge/+page.svelte, src/routes/primitives/card/+page.svelte, src/routes/primitives/descriptions/+page.svelte, src/routes/primitives/nav/+page.svelte, src/routes/primitives/tabs/+page.svelte, src/routes/styling/themes/+page.svelte, src/routes/styling/variants/+page.svelte |
| breadcrumb | none (native) | src/routes/primitives/breadcrumb/+page.svelte |
| button | none (native) | src/lib/components/CopyButton.svelte, src/lib/components/ThemeToggle.svelte, src/lib/components/TopNav.svelte, src/lib/home/ListenerLab.svelte, src/lib/home/Showcase.svelte, src/lib/home/Simple.svelte, src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/docs/+page.svelte, src/routes/docs/architecture/native-first/+page.svelte, src/routes/performance/results/+page.svelte, src/routes/performance/results/RowsSpecimen.svelte, src/routes/primitives/accordion/+page.svelte, src/routes/primitives/alert/+page.svelte, src/routes/primitives/autocomplete/examples/Palette.svelte, src/routes/primitives/badge/+page.svelte, src/routes/primitives/breadcrumb/+page.svelte, src/routes/primitives/button-group/+page.svelte, src/routes/primitives/button/+page.svelte, src/routes/primitives/calendar/+page.svelte, src/routes/primitives/card/+page.svelte, src/routes/primitives/checkbox/+page.svelte, src/routes/primitives/copy/+page.svelte, src/routes/primitives/dialog/+page.svelte, src/routes/primitives/empty/+page.svelte, src/routes/primitives/field/+page.svelte, src/routes/primitives/file-drop/+page.svelte, src/routes/primitives/hint/+page.svelte, src/routes/primitives/input/+page.svelte, src/routes/primitives/kbd/+page.svelte, src/routes/primitives/listbox/+page.svelte, src/routes/primitives/menu/+page.svelte, src/routes/primitives/nav/+page.svelte, src/routes/primitives/popover/+page.svelte, src/routes/primitives/progress/+page.svelte, src/routes/primitives/radio-group/+page.svelte, src/routes/primitives/rating/+page.svelte, src/routes/primitives/select/examples/Controlled.svelte, src/routes/primitives/select/examples/Form.svelte, src/routes/primitives/separator/+page.svelte, src/routes/primitives/skeleton/+page.svelte, src/routes/primitives/sortable/+page.svelte, src/routes/primitives/steps/+page.svelte, src/routes/primitives/tabs/+page.svelte, src/routes/primitives/timeline/+page.svelte, src/routes/primitives/toast/+page.svelte, src/routes/primitives/toolbar/+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-group | none (native) | src/routes/primitives/button-group/+page.svelte |
| calendar | @svelte-lean/primitives/calendar/register | src/routes/primitives/calendar/+page.svelte |
| card | none (native) | src/routes/primitives/card/+page.svelte, src/routes/primitives/descriptions/+page.svelte, src/routes/primitives/empty/+page.svelte, src/routes/styling/variants/+page.svelte |
| carousel | none (native) | src/routes/primitives/carousel/+page.svelte |
| checkbox | none (native) | /vercel/path0/packages/table/dist/table/data-table.svelte, src/lib/home/Showcase.svelte, src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/primitives/accordion/+page.svelte, src/routes/primitives/checkbox-group/+page.svelte, src/routes/primitives/checkbox/+page.svelte, src/routes/primitives/dialog/+page.svelte, src/routes/primitives/tabs/+page.svelte, src/routes/proof/+page.svelte, src/routes/styling/themes/+page.svelte, src/routes/table/layout/DensityBorders.svelte, src/routes/table/server-data/LoadingEmpty.svelte |
| checkbox-group | @svelte-lean/primitives/checkbox-group/register | src/lib/primitives/Gallery.svelte, src/routes/primitives/checkbox-group/+page.svelte, src/routes/primitives/checkbox/+page.svelte |
| copy | @svelte-lean/primitives/copy/register | src/lib/home/Hero.svelte, src/lib/home/Showcase.svelte, src/lib/home/Simple.svelte, src/lib/primitives/Gallery.svelte, src/routes/primitives/copy/+page.svelte, src/routes/primitives/findable/+page.svelte, src/routes/primitives/hint/+page.svelte, src/routes/primitives/input/+page.svelte |
| date-picker | @svelte-lean/primitives/date-picker/register | src/lib/home/Showcase.svelte, src/lib/primitives/Gallery.svelte, src/routes/primitives/date-picker/+page.svelte |
| descriptions | none (native) | src/routes/primitives/descriptions/+page.svelte |
| dialog | none (native) | src/lib/home/Showcase.svelte, src/lib/home/Simple.svelte, src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/docs/+page.svelte, src/routes/docs/architecture/native-first/+page.svelte, src/routes/primitives/autocomplete/examples/Palette.svelte, src/routes/primitives/dialog/+page.svelte, src/routes/proof/+page.svelte, src/routes/styling/variants/+page.svelte |
| empty | none (native) | src/routes/primitives/empty/+page.svelte |
| field | none (native) | src/lib/home/Showcase.svelte, src/lib/home/Stage.svelte, src/lib/query/examples/recipes/save-and-refresh.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/docs/+page.svelte, src/routes/primitives/accordion/+page.svelte, src/routes/primitives/badge/+page.svelte, src/routes/primitives/card/+page.svelte, src/routes/primitives/dialog/+page.svelte, src/routes/primitives/empty/+page.svelte, src/routes/primitives/field/+page.svelte, src/routes/primitives/input/+page.svelte, src/routes/primitives/meter/+page.svelte, src/routes/primitives/popover/+page.svelte, src/routes/primitives/rating/+page.svelte, src/routes/styling/variants/+page.svelte |
| field/validation | @svelte-lean/primitives/field/validation/register | src/routes/primitives/field/+page.svelte |
| file-drop | @svelte-lean/primitives/file-drop/register | src/routes/primitives/file-drop/+page.svelte |
| findable | @svelte-lean/primitives/findable/register | src/routes/primitives/findable/+page.svelte |
| hint | @svelte-lean/primitives/hint/register | src/lib/home/Hero.svelte, src/lib/home/Showcase.svelte, src/lib/home/Simple.svelte, src/lib/primitives/Gallery.svelte, src/routes/docs/+page.svelte, src/routes/primitives/button/+page.svelte, src/routes/primitives/copy/+page.svelte, src/routes/primitives/findable/+page.svelte, src/routes/primitives/hint/+page.svelte, src/routes/primitives/nav/+page.svelte, src/routes/table/+page.svelte |
| hover-card | @svelte-lean/primitives/hover-card/register | src/lib/primitives/Gallery.svelte, src/routes/primitives/hover-card/+page.svelte, src/routes/primitives/nav/+page.svelte |
| input | @svelte-lean/primitives/input/register | src/lib/home/Showcase.svelte, src/lib/primitives/Gallery.svelte, src/routes/primitives/input/+page.svelte, src/routes/styling/variants/+page.svelte |
| kbd | none (native) | src/routes/primitives/kbd/+page.svelte |
| listbox | @svelte-lean/primitives/listbox/register | src/lib/primitives/Gallery.svelte, src/routes/primitives/listbox/+page.svelte, src/routes/proof/+page.svelte |
| menu | @svelte-lean/primitives/menu/register | src/lib/home/Showcase.svelte, src/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/kbd/+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 |
| menu/contextmenu | @svelte-lean/primitives/menu/contextmenu/register | src/routes/primitives/menu/+page.svelte |
| menubar | @svelte-lean/primitives/menubar/register | src/routes/primitives/menubar/+page.svelte |
| meter | none (native) | src/routes/primitives/meter/+page.svelte |
| nav | none (native) | src/routes/primitives/nav/+page.svelte |
| pagination | none (native) | src/routes/primitives/pagination/+page.svelte |
| popover | none (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/breadcrumb/+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 |
| progress | none (native) | src/lib/home/Showcase.svelte, src/lib/primitives/Gallery.svelte, src/routes/primitives/progress/+page.svelte, src/routes/primitives/skeleton/+page.svelte, src/routes/styling/themes/+page.svelte, src/routes/styling/variants/+page.svelte |
| radio-group | none (native) | src/lib/home/Scale.svelte, src/lib/home/Showcase.svelte, src/lib/primitives/Gallery.svelte, src/routes/demos/settings/SettingsApp.svelte, src/routes/primitives/field/+page.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 |
| rating | none (native) | src/lib/home/Showcase.svelte, src/lib/primitives/Gallery.svelte, src/routes/primitives/rating/+page.svelte |
| scroll-area | none (native) | src/routes/primitives/scroll-area/+page.svelte |
| select | @svelte-lean/primitives/select/register | src/lib/home/Showcase.svelte, src/lib/home/Stage.svelte, src/lib/primitives/Gallery.svelte, src/routes/primitives/select/examples/Adornments.svelte, src/routes/primitives/select/examples/Clear.svelte, src/routes/primitives/select/examples/Colors.svelte, src/routes/primitives/select/examples/Controlled.svelte, src/routes/primitives/select/examples/Form.svelte, src/routes/primitives/select/examples/Fruit.svelte, src/routes/primitives/select/examples/Groups.svelte, src/routes/primitives/select/examples/Localized.svelte, src/routes/primitives/select/examples/Looks.svelte, src/routes/primitives/select/examples/Multiple.svelte, src/routes/primitives/select/examples/Search.svelte, src/routes/primitives/select/examples/Sizes.svelte, src/routes/primitives/select/examples/States.svelte, src/routes/primitives/select/examples/Tags.svelte, src/routes/proof/+page.svelte |
| select/create | @svelte-lean/primitives/select/create/register | src/routes/primitives/select/examples/Tags.svelte |
| select/search | @svelte-lean/primitives/select/search/register | src/lib/home/Showcase.svelte, src/lib/home/Stage.svelte, src/routes/primitives/select/examples/Search.svelte, src/routes/primitives/select/examples/Tags.svelte |
| separator | none (native) | src/routes/primitives/separator/+page.svelte |
| skeleton | none (native) | src/routes/primitives/skeleton/+page.svelte, src/routes/styling/variants/+page.svelte |
| slider | @svelte-lean/primitives/slider/register | src/lib/home/Showcase.svelte, src/lib/primitives/Gallery.svelte, src/routes/primitives/slider/+page.svelte |
| sortable | @svelte-lean/primitives/sortable/register | src/routes/primitives/sortable/+page.svelte |
| splitter | @svelte-lean/primitives/splitter/register | src/lib/primitives/Gallery.svelte, src/routes/primitives/splitter/+page.svelte |
| steps | none (native) | src/lib/primitives/Gallery.svelte, src/routes/primitives/steps/+page.svelte |
| tabs | @svelte-lean/primitives/tabs/register | src/lib/components/InstallBlock.svelte, src/lib/components/TabsRoots.svelte, src/lib/home/Showcase.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 |
| timeline | none (native) | src/routes/primitives/timeline/+page.svelte |
| toast | @svelte-lean/primitives/toast/register | src/lib/home/Showcase.svelte, src/lib/primitives/Gallery.svelte, src/routes/primitives/toast/+page.svelte |
| toggle | @svelte-lean/primitives/toggle-group/register | src/routes/primitives/toggle/+page.svelte, src/routes/primitives/toolbar/+page.svelte |
| toggle-group | @svelte-lean/primitives/toggle-group/register | src/lib/home/Showcase.svelte, src/lib/primitives/Gallery.svelte, src/routes/primitives/toggle-group/+page.svelte, src/routes/primitives/toolbar/+page.svelte |
| toolbar | @svelte-lean/primitives/toolbar/register | src/lib/home/Showcase.svelte, src/routes/primitives/toolbar/+page.svelte |
| tree | @svelte-lean/primitives/tree/register | src/lib/primitives/Gallery.svelte, src/routes/primitives/tree/+page.svelte |

## Build report

`report: true` prints what the scan found. Sizes and listener counts come from the consumer fixtures, not from the plugin.

`Build output`

```bash
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`

```ts
svelteLean({
	include: ['**/*.svelte'],
	exclude: ['**/node_modules/**'],
	package: '@svelte-lean/primitives',
	behaviors: {},
	manifest: true,
	report: false,
	warnDynamic: true,
	ssr: 'skip'
});
```

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| # `include` | `string \| RegExp \| (string \| RegExp)[]` | `['**/*.svelte']` | Files to scan; globs match the root-relative and the absolute path. |
| # `exclude` | `string \| RegExp \| (string \| RegExp)[]` | `['**/node_modules/**']` | Files never scanned or injected into. Tested after include. |
| # `package` | `string` | `'@svelte-lean/primitives'` | Another package that provides the same registration modules. |
| # `behaviors` | `Record<string, string \| false>` | `{}` | Extends the built-in table: a module to inject, or false for no runtime. |
| # `manifest` | `boolean \| string` | `true` | Write node\_modules/.svelte-lean/manifest.json (or the given path) after a successful production build. |
| # `report` | `boolean` | `false` | Print the build report; SSR builds are silent. |
| # `warnDynamic` | `boolean` | `true` | Warn once per file, with position, about data-slean={expression} markers. |
| # `ssr` | `'skip' \| 'inject'` | `'skip'` | Whether server modules also get the imports (a server bundle size choice only). |

The built-in table maps Tier 1 and 2 primitives to their register modules and Tier 0 (plus `radio`) to none; a test keeps it equal to the package metadata. An unknown name is reported once and injects nothing.

## The shared router

- `registerBehavior()` installs at most one document listener per declared event type; pointer-move and scroll are never installed generically.
- Routing walks `event.composedPath()` and reads `data-slean` on the way: nested behaviors work and no selector runs over the document.

```ts
// @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 }) { … }
	}
});
```

- A root inserted later is found on its first event, so there is no `MutationObserver` doing work on unrelated DOM updates.
- Registration is idempotent and waits for a browser, so the module imports under Node.

```ts
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);
```

Release gate: 1000 tabs roots keep one `click` and one `keydown` listener, counted by the runtime and an independent `addEventListener` wrapper (listeners.spec.ts). The [proof page](https://svelte-lean.vercel.app/proof.md) repeats the reading in your browser.

## Measured

Svelte Lean chunk of each production fixture:

- tabs only: 1839 B brotli
- manual registration, no plugin, same module set: 1839 B brotli
- tabs and menu, one kernel: 4600 B brotli
- native markup only: no chunk

## Source

- packages/vite/src: `scanner.ts`, `plugin.ts`, `manifest.ts`, `report.ts`, `behaviors.ts`
- packages/vite/tests: scanner, plugin, and a real `vite build` of a fixture application in both plugin orders
- core/src/runtime.ts and registry.ts: the router and the idempotent registry
- ADR 0004 and ADR 0005
- fixtures/: the consumer builds and their assertions
