@lark.js/mvc 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,131 +1,59 @@
1
- ---
2
- name: lark-mvc
3
- description: >
4
- Comprehensive guide to the Lark MVC Framework (@lark.js/mvc) for building
5
- TypeScript SPAs. Use this skill any time the user works with Lark — creating
6
- Views with View.extend() or defineView(), defining reactive Stores with
7
- defineStore() / computed() / multi(), wiring State for cross-view data,
8
- setting up Router (including Router.beforeEach async guards), writing
9
- HTML templates with {{=}}/{{forOf}}/{{if}}/@event/v-lark syntax, configuring
10
- the Vite plugin or Webpack loader, registering Views with registerViewClass,
11
- integrating Module Federation with CrossSite, calling Service for API
12
- requests with caching/dedup/queue, or anything mentioning Frame trees,
13
- hash routing, real-DOM diff, capture-phase event delegation, or the v-lark
14
- attribute. Also trigger on Lark-related debugging (window.__lark_Debug,
15
- Frame Visualizer Bridge, ldk/lak/lvk attributes) and on questions about
16
- Lark's three data pipelines (Updater / State / Store) or migration patterns
17
- between them.
18
- ---
1
+ ## @lark.js/mvc
19
2
 
20
- # Lark MVC Framework
3
+ A TypeScript MVC framework designed for back-office single-page applications and micro-frontend scenarios.
21
4
 
22
- `@lark.js/mvc` is a TypeScript MVC framework for single-page applications. It pairs a strict Model-View-Controller layout with Proxy-based reactive state, hash-based routing, and a real-DOM diff renderer. The framework treats micro-frontend integration (Module Federation) as a first-class concern.
5
+ `@lark.js/mvc` explicitly separates Model, View, and Controller layers: state management aligns with the zustand design (`create` / `getState` / `setState` / `subscribe`), routing supports both history and hash modes, templates compile to functions and render via real DOM diff, and micro-frontends are natively supported through the built-in CrossSite bridge and first-class Webpack Module Federation integration. The framework has zero runtime third-party dependencies; the template runtime helper module weighs approximately 1 KB (`dist/runtime.js` measured at 964 bytes).
23
6
 
24
- This guide walks through architecture, the full public API, project layout, the three data pipelines, the template language, the build-tool integrations, and the common pitfalls. For exhaustive API signatures and template syntax, follow the pointers in [References](#references) at the end.
7
+ - Package: `@lark.js/mvc`
8
+ - Version: see `package.json` (currently 0.0.5)
9
+ - Entry points: `./` main entry, `./vite` build plugin, `./webpack` loader, `./runtime` template runtime
10
+ - Build: tsup, producing ESM + CJS + `.d.ts` in `dist/`
11
+ - Tests: vitest, 16 test files covering core modules
25
12
 
26
- ## When to reach for this skill
13
+ ## Table of Contents
27
14
 
28
- Any task that names or clearly implies — Lark:
15
+ - Design Goals and Use Cases
16
+ - Installation and Build Tool Configuration
17
+ - Five-Minute Quick Start
18
+ - Three Data Pipelines: Updater / State / Store
19
+ - View Definition and Lifecycle
20
+ - Router and Route Guards
21
+ - Service Request Layer
22
+ - Template Syntax
23
+ - Frame and the View Tree
24
+ - Module Federation Micro-Frontend
25
+ - Debugging and DevTools Bridge
26
+ - Public API Reference
27
+ - Common Pitfalls
28
+ - Recent API Changes
29
+ - Comparison with Vue 3 / React 19
30
+ - Testing and Local Development
29
31
 
30
- - Creating, extending, or registering Views; wiring view event handlers; setting up view lifecycle (`init`, `make`, `assign`, `render`, `destroy`).
31
- - Designing reactive state with `defineStore`, `cell`, `computed`, `multi`, or cross-view sharing through `State`.
32
- - Routing tasks: hash navigation, route guards (`Router.beforeEach`), two-phase `change`/`changed` events, `Router.to(...)`.
33
- - Authoring `.html` templates with the `{{=}}` / `{{forOf}}` / `{{if}}` / `@event` / `v-lark` syntax.
34
- - Configuring the Vite plugin (`larkMvcPlugin`) or Webpack loader (`larkMvcLoader`).
35
- - Embedding remote views via Module Federation (`CrossSite`, `FrameworkConfig.require`).
36
- - API request layers using `Service.extend`, `Service.add`, `service.all/one/save`, `cleanKeys`.
37
- - Debugging Frame trees, working with `window.__lark_Debug`, or the Frame Visualizer Bridge.
32
+ ## Design Goals and Use Cases
38
33
 
39
- ## Architecture
34
+ Lark's trade-offs center around one category of requirements: back-office business systems with deep route hierarchies, heavy forms and API calls, and the need to compose several independent applications into a single shell. The framework makes explicit choices along the following dimensions.
40
35
 
41
- Lark separates code along three orthogonal axes:
36
+ First, explicit layering. The Model layer provides `State` / `Store` (zustand-style) / `Service`, the View layer provides `View` / `Updater`, and the Controller layer provides `Router` (history/hash dual mode) / `Frame`. These communicate through explicit interfaces and events, allowing new team members to locate code by layer.
42
37
 
43
- - **Model**: `State` (simple global singleton, recommended for lightweight cross-view values), `defineStore` (Proxy-based reactive store with handlers, `computed`, multi-instance support recommended for complex reactive state), `cell`/`observeCell` (standalone reactive cells), `Service` (API request manager with LFU cache + deduplication + serial queue).
44
- - **View**: `View.extend()` and the typed `defineView()` factory both produce View subclasses. Views own templates, event handlers, the lifecycle, the per-view `Updater`, and resource bookkeeping.
45
- - **Controller**: `Router` (hash routing, two-phase change confirmation, `beforeEach` async guards), `Updater` (per-view data binding, change tracking, VDOM diff), `Frame` (the runtime tree of view containers, mount/unmount lifecycle, deferred `invoke` queue).
38
+ Second, native micro-frontend support. The `CrossSite` bridge view + `FrameworkConfig.require` + Module Federation form a complete pipeline. Write `v-lark="remote-app/views/home"` in a template and the remote view loads and mounts automatically, eliminating the need for secondary containers like single-spa or qiankun.
46
39
 
47
- ### The three data pipelines
40
+ Third, zero runtime dependencies. `@babel/parser` / `@babel/types` are used only at build time for template parsing. The runtime helper module `@lark.js/mvc/runtime` contains five functions (`strSafe` / `encHtml` / `encUri` / `encQuote` / `refFn`) and weighs approximately 1 KB as ESM.
48
41
 
49
- Lark exposes three ways to flow data to a view. Pick the simplest one that solves the task.
42
+ Fourth, real DOM diff. Templates compile to functions that produce HTML strings, which are parsed into temporary DOM via `document.implementation.createHTMLDocument` and then diffed against the live DOM using keyed comparison. The advantage is that context-sensitive tags like `<table>` / `<select>` / `<svg>` are handled by the native parser. The trade-off is that large templates incur parse overhead, and SSR is not supported.
50
43
 
51
- 1. **Updater pipeline** (view-local). Use when only the current view reads and writes the data.
52
- `updater.set(data)` → `updater.digest()` → compiled template function → HTML string → `vdomGetNode` parses it into a temporary DOM tree → `vdomSetChildNodes` diffs against the live DOM → DOM ops applied → `endUpdate()` notifies child frames.
44
+ Fifth, debug-friendly. `window.__lark_Debug = true` enables Safeguard Proxy protection against cross-page pollution and accidental writes. `installFrameVisualizerBridge` exposes the Frame tree to visual DevTools via `postMessage`. A set of `window.__lark_*` global shortcuts cover Framework / State / Router / Frame / View and HMR helpers.
53
45
 
54
- 2. **State pipeline** (simple cross-view, recommended for lightweight shared values like counters, toggles, page title, session info).
55
- `State.set(data)` → `State.digest()` → `changed` event fires with `keys: ReadonlySet<string>` → views listening read via `State.get()` in their `assign()` → standard Updater path. State uses key reference counting; pair with `mixins: [State.clean("a,b")]` so keys are removed when the last view unmounts.
46
+ Not suitable for: projects requiring SSR/streaming rendering, cross-platform needs like React Native, or projects needing off-the-shelf Chrome extension panels. For those, consider the React or Vue ecosystems.
56
47
 
57
- 3. **Store pipeline** (complex cross-view, recommended when you need reactive handlers, derived data, multi-instance isolation, or store-internal reactions).
58
- `store.key = value` → Proxy `set` trap → `trigger()` → `GlobalDeps` lookup → microtask-batched `Queue` → `store.observe` callbacks → standard Updater path. Supports `computed(deps, fn)` for derived state.
48
+ ## Installation and Build Tool Configuration
59
49
 
60
- ### Boot sequence (order matters)
61
-
62
- `Framework.boot(config)` runs these steps in this exact order:
63
-
64
- 1. Merge user config into the shared `config` object.
65
- 2. Inject the merged config into `Router` via `Router._setConfig`.
66
- 3. Set the EventDelegator's frame getter so global events can find views.
67
- 4. Subscribe Router and State `changed` events to the dispatcher.
68
- 5. Mark Framework / Router / State as booted.
69
- 6. Install the Frame Visualizer Bridge (`postMessage` listener for DevTools).
70
- 7. **Create the root Frame with `Frame.createRoot(config.rootId)` BEFORE step 8.**
71
- 8. **Bind `Router._bind()` so hashchange/popstate/beforeunload fire — and Router.diff() runs once initially.**
72
- 9. Mount the `defaultView` ONLY if Router didn't already mount one (e.g., after a page reload with `#!/counter`).
73
-
74
- The root must exist before `Router._bind()` because the initial `diff()` may immediately fire CHANGED → `dispatcherNotifyChange` → `Frame.getRoot()`. If the root didn't exist yet, Router would default to `"root"` and the view would render into the wrong element.
75
-
76
- ### Window globals
77
-
78
- After boot, the framework attaches these to `window` for debugging and HMR:
79
-
80
- | Global | Value | Purpose |
81
- | ------------------------------------ | ---------------- | ------------------------------------- |
82
- | `window.__lark_Framework` | Framework object | Direct framework access |
83
- | `window.__lark_State` | State object | Direct state access |
84
- | `window.__lark_Router` | Router object | Direct router access |
85
- | `window.__lark_Frame` | Frame class | Direct Frame class access |
86
- | `window.__lark_View` | View class | Direct View class access |
87
- | `window.__lark_registerViewClass` | function | HMR helper: re-register a View class |
88
- | `window.__lark_invalidateViewClass` | function | HMR helper: drop a View from registry |
89
- | `window.__lark_getViewClassRegistry` | function | HMR helper: read the View registry |
90
- | `window.__lark_Debug` | boolean (opt-in) | Enables Safeguard Proxy debug checks |
91
-
92
- Set `window.__lark_Debug = true` before boot to enable Safeguard Proxy wrapping on `State.get()` reads, `Router.diff()` results, Location params, and `Updater.get()` — it warns when data set on one page is read from a different page, and when something tries to mutate `State.get()` data directly instead of going through `State.set()` + `State.digest()`.
93
-
94
- ## Project structure
95
-
96
- ```
97
- project/
98
- ├─ index.html # entry, references <script type="module" src="/src/boot.ts">
99
- ├─ vite.config.ts # OR webpack.config.mjs
100
- └─ src/
101
- ├─ boot.ts # registerViewClass(...) + Framework.boot(config)
102
- ├─ view.ts # project-wide base view (re-export of defineView/View.extend)
103
- ├─ styles.css
104
- ├─ store/
105
- │ └─ count.ts # defineStore declarations
106
- ├─ views/
107
- │ ├─ home.ts
108
- │ ├─ home.html # compiled by larkMvcPlugin / larkMvcLoader
109
- │ ├─ about.ts
110
- │ └─ about.html
111
- └─ components/ # sub-views embedded via v-lark
112
- ├─ counter-store.ts
113
- └─ counter-store.html
114
- ```
115
-
116
- `boot.ts` must live inside `src/` — `index.html` references it as `/src/boot.ts`. Putting it at the project root breaks the import resolution at runtime.
117
-
118
- ## Quick start
119
-
120
- ### 1. Install
50
+ ### Installation
121
51
 
122
52
  ```bash
123
53
  pnpm add @lark.js/mvc
124
54
  ```
125
55
 
126
- ### 2. Configure your bundler
127
-
128
- Vite (recommended):
56
+ ### Vite (Recommended)
129
57
 
130
58
  ```ts
131
59
  // vite.config.ts
@@ -139,40 +67,42 @@ export default defineConfig({
139
67
  });
140
68
  ```
141
69
 
142
- The plugin runs in the `pre` phase. Its `resolveId` hook tags `.html` imports with a `?lark-template` suffix so Vite doesn't treat them as static assets, then its `load` hook compiles the raw HTML through `extractGlobalVars()` + `compileTemplate()` into an ES module exporting `(data, viewId, refData) => string`.
70
+ `larkMvcPlugin()` registers at the `enforce: "pre"` stage: the `resolveId` hook appends a `?lark-template` suffix to `.html` imports to prevent Vite from treating them as static assets; the `load` hook calls `extractGlobalVars()` and `compileTemplate()` to compile templates into ES modules exporting a render function of the form `(data, viewId, refData) => string`. With `{ debug: true }`, source location markers are injected into the output so runtime errors can be traced back to the original HTML line.
143
71
 
144
- For Webpack, mirror the same idea with the loader:
72
+ ### Webpack
145
73
 
146
74
  ```js
147
75
  // webpack.config.mjs
148
76
  import { larkMvcLoader } from "@lark.js/mvc/webpack";
149
77
 
150
78
  export default {
151
- // ...
152
79
  module: {
153
80
  rules: [
154
81
  { test: /\.ts$/, use: "ts-loader", exclude: /node_modules/ },
155
82
  {
156
83
  test: /\.html$/,
157
84
  use: [{ loader: larkMvcLoader }],
158
- exclude: /index\.html$/, // HtmlWebpackPlugin handles the entry HTML
85
+ exclude: /index\.html$/,
159
86
  },
160
87
  ],
161
88
  },
162
89
  };
163
90
  ```
164
91
 
165
- Both integrations accept `{ debug: true }` to inject source-position markers into the compiled template, so runtime errors point back to the original `.html` line and expression.
92
+ Two important notes: the `loader` field must be imported as a value (`loader: larkMvcLoader`), not as a string name; you must use `exclude: /index\.html$/` to let `HtmlWebpackPlugin` handle the entry HTML, otherwise it will be compiled as a template.
93
+
94
+ Both integrations share the same compilation pipeline: `extractGlobalVars` extracts external variables referenced in the template, and `compileTemplate` produces an ES module that imports helper functions from `@lark.js/mvc/runtime`.
95
+
96
+ ## Five-Minute Quick Start
166
97
 
167
- ### 3. Entry HTML
98
+ ### Entry HTML
168
99
 
169
100
  ```html
170
101
  <!doctype html>
171
102
  <html lang="en">
172
103
  <head>
173
104
  <meta charset="UTF-8" />
174
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
175
- <title>My Lark App</title>
105
+ <title>Lark App</title>
176
106
  </head>
177
107
  <body>
178
108
  <div id="app"></div>
@@ -181,9 +111,9 @@ Both integrations accept `{ debug: true }` to inject source-position markers int
181
111
  </html>
182
112
  ```
183
113
 
184
- The `<div id="app">` matches `rootId: "app"` in the boot config.
114
+ `<div id="app">` corresponds to `rootId: "app"` in the boot configuration. `boot.ts` must reside in `src/` because the HTML references `/src/boot.ts`; placing it at the project root will cause a runtime resolution failure.
185
115
 
186
- ### 4. A project-level base View
116
+ ### Project-Level Base View
187
117
 
188
118
  ```ts
189
119
  // src/view.ts
@@ -191,11 +121,8 @@ import { defineView, Router } from "@lark.js/mvc";
191
121
 
192
122
  export default defineView({
193
123
  make() {
194
- // Called once per instance via the merged ctors[] pipeline.
195
124
  this.updater.set({ appName: "My App" });
196
- this.on("destroy", () => {
197
- console.log(`View destroyed: ${this.id}`);
198
- });
125
+ this.on("destroy", () => console.log(`view destroyed: ${this.id}`));
199
126
  },
200
127
  navigate(path: string, params?: Record<string, unknown>) {
201
128
  Router.to(path, params);
@@ -203,9 +130,66 @@ export default defineView({
203
130
  });
204
131
  ```
205
132
 
206
- `defineView` is a thin, type-safe wrapper around `View.extend`: it threads the literal's own shape into `this` via `ThisType<P & ViewInterface>`, so `this.appName` inside `make` is typed without manual casts. Runtime behavior is identical to `View.extend({...})`.
133
+ `defineView` is a typed wrapper around `View.extend`: via `ThisType<P & ViewInterface>` it threads the literal's own fields into `this`, so writing `this.appName` in `make` requires no cast. Runtime behavior is equivalent to `View.extend({...})`.
134
+
135
+ ### View and Template
136
+
137
+ ```html
138
+ <!-- src/views/home.html -->
139
+ <div>
140
+ <h1>{{=title}}</h1>
141
+ <p>Count: {{=count}}</p>
142
+ <button @click="incr()">+1</button>
143
+ {{if count > 0}}
144
+ <p>Positive</p>
145
+ {{else}}
146
+ <p>Zero or negative</p>
147
+ {{/if}}
148
+ <ul>
149
+ {{forOf items as item idx}}
150
+ <li id="item-{{=item.id}}">{{=idx}}: {{=item.name}}</li>
151
+ {{/forOf}}
152
+ </ul>
153
+ <div v-lark="components/counter-store"></div>
154
+ </div>
155
+ ```
156
+
157
+ ```ts
158
+ // src/views/home.ts
159
+ import { bindStore } from "@lark.js/mvc";
160
+ import View from "../view";
161
+ import template from "./home.html";
162
+ import useCountStore from "../store/count";
163
+
164
+ export default View.extend({
165
+ template,
166
+ init() {
167
+ this.assign();
168
+ bindStore(this, useCountStore, (s) => ({ count: s.count }));
169
+ },
170
+ assign() {
171
+ this.updater.snapshot();
172
+ const { count } = useCountStore.getState();
173
+ this.updater.set({
174
+ title: "Home",
175
+ count,
176
+ items: [
177
+ { id: "a", name: "Alpha" },
178
+ { id: "b", name: "Beta" },
179
+ ],
180
+ });
181
+ return this.updater.altered();
182
+ },
183
+ render() {
184
+ this.updater.digest();
185
+ },
186
+ "incr<click>"() {
187
+ useCountStore.getState().increment();
188
+ },
189
+ });
190
+ ```
207
191
 
208
- ### 5. Boot
192
+ ### Boot
209
193
 
210
194
  ```ts
211
195
  // src/boot.ts
@@ -228,7 +212,7 @@ const config: FrameworkConfig = {
228
212
  "/about": "about",
229
213
  },
230
214
  unmatchedView: "404",
231
- error(e: Error) {
215
+ error(e) {
232
216
  console.error("Lark error:", e);
233
217
  },
234
218
  };
@@ -236,397 +220,357 @@ const config: FrameworkConfig = {
236
220
  Framework.boot(config);
237
221
  ```
238
222
 
239
- All view classes must be registered _before_ `Framework.boot()`. The registry lives in `src/view-registry.ts` and is exposed through `registerViewClass` (re-exported from `@lark.js/mvc`).
223
+ `Framework.boot()` executes the following steps in order (order is correctness-sensitive): merge user config (including `routeMode`), inject config into Router (which determines history/hash mode), set EventDelegator's frame getter, subscribe to Router/State `changed` events, mark Framework/Router/State as booted, install the Frame Visualizer Bridge, create the root Frame via `Frame.createRoot(config.rootId)`, call `Router._bind()` to bind route events (popstate for history mode, hashchange + popstate for hash mode) and trigger the first `diff()`, and finally mount `defaultView` if Router has not mounted a view. Step seven must precede step eight because the first `diff()` may immediately trigger `CHANGED` followed by `Frame.getRoot()`, and if the root Frame does not exist it degrades to rendering against the wrong element.
240
224
 
241
- ## Defining Stores
225
+ ## Three Data Pipelines: Updater / State / Store
242
226
 
243
- ### Basic store
227
+ Lark provides three data flow mechanisms simultaneously, ranging from simple to complex, corresponding to "view-private", "lightweight cross-view sharing", and "complex reactive cross-view sharing". Choose the simplest approach that meets your needs to reduce cognitive overhead.
244
228
 
245
- ```ts
246
- // src/store/count.ts
247
- import { defineStore } from "@lark.js/mvc";
229
+ ### Updater: View-Private
248
230
 
249
- interface CountStore {
250
- count: number;
251
- step: number;
252
- doubled: number; // computed
253
- history: string[];
254
- increment: () => void;
255
- decrement: () => void;
256
- reset: () => void;
257
- }
231
+ `Updater` is each View's local data manager. All intra-view data flow ultimately goes through the Updater:
258
232
 
259
- const useCountStore = defineStore<CountStore>(
260
- "count",
261
- (store, { computed, lazySet, shallowSet }) => ({
262
- count: 0,
263
- step: 1,
264
- doubled: computed(["count"], () => store.count * 2),
265
- history: [] as string[],
266
- increment() {
267
- store.count = store.count + store.step;
268
- store.history = [...store.history, `+${store.step} -> ${store.count}`];
269
- },
270
- decrement() {
271
- store.count = store.count - store.step;
272
- store.history = [...store.history, `-${store.step} -> ${store.count}`];
273
- },
274
- reset() {
275
- store.count = 0;
276
- store.history = [];
277
- },
278
- }),
279
- );
280
-
281
- export default useCountStore;
233
+ ```ts
234
+ this.updater.set({ count: newCount });
235
+ this.updater.digest();
282
236
  ```
283
237
 
284
- ### How the creator runs
238
+ Full pipeline: `updater.set(data)` shallow-merges data into the internal data object and collects changed keys. `updater.digest()` calls the compiled template function to generate an HTML string. `vdomGetNode` uses `tmp.innerHTML = wrap + html` to parse it into temporary DOM. `vdomSetChildNodes` compares against the live DOM to produce a keyed diff. DOM operations are applied in batch. `endUpdate()` notifies child Frames to complete mounting.
285
239
 
286
- The creator runs once at definition time. Lark walks the return value:
240
+ Supports digest re-entry: calling `updater.digest()` during an active digest does not nest; instead it queues to `digestingQueue` and executes after the current digest completes. `null` serves as a digest boundary sentinel in the queue.
287
241
 
288
- - Function entries become **handlers** on the store proxy (`store.increment()` invokes the original closure).
289
- - `computed(deps, fn)` markers occupy a reactive state slot. After every other state key is initialized, `computed` fires its `fn()` to produce the initial value, then registers an internal `track` so the dep keys re-run `fn` when they change. Writes to a computed key are silently ignored.
290
- - Everything else becomes initial state and is registered with the reactive Proxy.
242
+ ### State: Lightweight Cross-View
291
243
 
292
- ### How `useStore(view)` works
244
+ `State` is a global singleton key-value container, suitable for lightweight shared values like page title, login info, or current theme:
293
245
 
294
- - `useStore()` (no view) — read-only access for module-level utilities.
295
- - `useStore(view)` registers the view in the store's `boundViews` set; on view destroy, the view is automatically detached.
296
- - Reading a state key (e.g. `store.count`) outside the creator returns a **deep-cloned copy** (via the native `structuredClone` when available). Mutating the returned value does NOT trigger reactivity.
297
- - Writing a state key (`store.count = 5`) goes through the reactive Proxy and triggers observers.
298
- - **Inside the creator**, `store.count` returns the raw reactive Proxy — direct mutation works and is reactive.
246
+ ```ts
247
+ import { State } from "@lark.js/mvc";
299
248
 
300
- ### Subscribing a view
249
+ State.set({ pageTitle: "Home", isLoggedIn: true });
250
+ State.digest();
251
+ ```
301
252
 
302
- `store.observe(view, keys?, defaultCallback?)` subscribes the view to store changes. Variations:
253
+ Subscription has two approaches. First, declare `observeState` in a view, and the framework automatically re-renders when the corresponding keys change:
303
254
 
304
255
  ```ts
305
- // Default: observe every state key in the store (including computeds).
306
- // Avoids the "two lists to keep in sync" pain point.
307
- store.observe(this);
256
+ export default View.extend({
257
+ template,
258
+ observeState: "pageTitle,isLoggedIn",
259
+ assign() {
260
+ this.updater.snapshot();
261
+ this.updater.set({
262
+ title: State.get("pageTitle"),
263
+ logged: State.get("isLoggedIn"),
264
+ });
265
+ return this.updater.altered();
266
+ },
267
+ });
268
+ ```
308
269
 
309
- // Explicit: only the listed keys trigger updates.
310
- store.observe(this, ["count", "step"]);
270
+ Second, listen directly to the `changed` event where `e.keys` is a `ReadonlySet<string>`:
311
271
 
312
- // With a custom callback (override the default updater.digest behavior).
313
- store.observe(this, ["count"], (changedMap) => {
314
- console.log("count changed", changedMap);
272
+ ```ts
273
+ State.on("changed", (e) => {
274
+ if (e.keys?.has("pageTitle")) console.log("Title changed");
315
275
  });
276
+ ```
316
277
 
317
- // Fine-grained ObservePayload entries.
318
- store.observe(this, [
319
- { key: "count", alias: "currentCount", lazy: false },
320
- {
321
- key: "items",
322
- transform: (val) => ({ itemCount: (val as unknown[]).length }),
323
- },
324
- ]);
278
+ State manages lifecycle through reference counting on keys. Best practice is to add a `State.clean` mixin to all consumers, ensuring that when the last observer is destroyed, the key is automatically reclaimed:
325
279
 
326
- // Inner observe — no view binding, for store-internal reactions.
327
- // Keys are required (no useful default for a callback-only observer).
328
- store.observe(undefined, ["step"], () => {
329
- store.count = 0; // reset count when step changes
280
+ ```ts
281
+ export default View.extend({
282
+ mixins: [State.clean("pageTitle,isLoggedIn")],
283
+ template,
330
284
  });
331
285
  ```
332
286
 
333
- `lazy: true` (the default) calls `updater.digest()` to merge data. `lazy: false` calls `updater.set()` first, then `digest()` — useful when the callback supplies a transformed value that should land on the data object before the next render.
287
+ Without cleanup, keys persist on global State causing leaks.
334
288
 
335
- Inner observes are de-duplicated by `key + observeKeys.join("-") + cb.toString()`, so the same inner observe with identical key/callback won't register twice.
289
+ ### Store: Zustand-Style State Management
336
290
 
337
- ### Multi-instance stores
338
-
339
- When a component is reused N times and each instance needs its own state:
291
+ The Store API aligns with zustand's design: `create(name, (set, get) => body)` returns a `StoreApi` object providing `getState` / `setState` / `subscribe` / `destroy`. State is a plain object with no Proxy; all writes must go through `setState` or actions. `bindStore(view, store, selector?)` binds a store to a Lark View with automatic unsubscription on view destruction.
340
292
 
341
293
  ```ts
342
- import { defineStore, multi } from "@lark.js/mvc";
294
+ // src/store/count.ts
295
+ import { create, computed } from "@lark.js/mvc";
343
296
 
344
- const useCounterStore = defineStore("counter", (store) => ({
297
+ interface CountStore {
298
+ count: number;
299
+ step: number;
300
+ doubled: number;
301
+ history: string[];
302
+ increment: () => void;
303
+ decrement: () => void;
304
+ reset: () => void;
305
+ }
306
+
307
+ const useCountStore = create<CountStore>("count", (set, get) => ({
345
308
  count: 0,
309
+ step: 1,
310
+ doubled: computed(["count"], () => get().count * 2),
311
+ history: [] as string[],
346
312
  increment() {
347
- store.count++;
313
+ const { count, step } = get();
314
+ set({
315
+ count: count + step,
316
+ history: [...get().history, `+${step} -> ${count + step}`],
317
+ });
318
+ },
319
+ decrement() {
320
+ const { count, step } = get();
321
+ set({ count: count - step });
322
+ },
323
+ reset() {
324
+ set({ count: 0, history: [] });
348
325
  },
349
326
  }));
350
327
 
351
- // multi() returns [useFn, mixinObj].
352
- const [useMultiCounter, counterMixin] = multi(useCounterStore);
353
-
354
- export default defineView({
355
- mixins: [counterMixin], // its make() stamps a per-instance flag onto the view
356
- template,
357
- init() {
358
- const store = useMultiCounter(this); // each instance gets its own store clone
359
- store.observe(this, ["count"]);
360
- },
361
- });
328
+ export default useCountStore;
362
329
  ```
363
330
 
364
- `multi()` intercepts the parent frame's `mountFrame` to propagate a `lark-comp-<storeName>` flag down the Frame tree. When `useFn(view)` is called, it looks up the flag and either returns an existing store clone or creates one via `cloneStore()`.
331
+ The creator function receives `(set, get)` and executes once during `create`. Lark iterates the return value: functions become actions (attached to state, unaffected by `setState`); `computed(deps, fn)` occupies a derived slot, running `fn()` once for the initial value and recomputing whenever any dep key changes via `setState`; all other fields become initial state. Writing to a computed key via `setState` is silently ignored.
365
332
 
366
- ### Standalone reactive cells
367
-
368
- For one-off reactive values that don't need a full store:
333
+ Reading and writing state:
369
334
 
370
335
  ```ts
371
- import { cell, observeCell } from "@lark.js/mvc";
372
-
373
- const count = cell({ value: 0 });
374
- const off = observeCell(count, () => console.log("changed:", count.value));
375
- count.value = 1; // triggers callback
376
- off(); // clean up
377
- ```
378
-
379
- `cell(data)` creates a Proxy with `belong: "lark-global"` and a unique `linkKeys`. `observeCell(state, cb, immediate?)` registers a tracker (defaults to firing immediately).
336
+ // Read
337
+ const { count, step } = useCountStore.getState();
380
338
 
381
- ## Defining Views
339
+ // Write (shallow merge)
340
+ useCountStore.setState({ count: 5 });
341
+ useCountStore.setState((prev) => ({ count: prev.count + 1 }));
382
342
 
383
- ### View template
384
-
385
- ```html
386
- <!-- src/views/home.html -->
387
- <div>
388
- <h1>{{=title}}</h1>
389
- <div>Count: {{=count}}</div>
390
- <button @click="navigateTo({path: '/about'})">About</button>
391
-
392
- {{if count > 0}}
393
- <p>Positive</p>
394
- {{else}}
395
- <p>Zero or negative</p>
396
- {{/if}}
397
-
398
- <ul>
399
- {{forOf items as item idx}}
400
- <li id="item-{{=item.id}}">{{=idx}}: {{=item.name}}</li>
401
- {{/forOf}}
402
- </ul>
403
-
404
- <!-- Sub-view embedding -->
405
- <div v-lark="components/child"></div>
406
- </div>
343
+ // Call action
344
+ useCountStore.getState().increment();
407
345
  ```
408
346
 
409
- ### View class
347
+ Binding in a view:
410
348
 
411
349
  ```ts
412
- // src/views/home.ts
413
- import { Router } from "@lark.js/mvc";
414
- import View from "../view"; // project-level base
415
- import template from "./home.html";
416
- import useCountStore from "../store/count";
350
+ import { bindStore } from "@lark.js/mvc";
417
351
 
418
352
  export default View.extend({
419
353
  template,
420
-
421
354
  init() {
422
- this.assign();
355
+ // Bind all non-function state keys to view updater; auto-unsubscribes on destroy
356
+ bindStore(this, useCountStore);
423
357
 
424
- const store = useCountStore(this);
425
- store.observe(this); // observe every store key (D5 default)
358
+ // Or use a selector to sync only specific keys
359
+ bindStore(this, useCountStore, (s) => ({ count: s.count }));
426
360
  },
361
+ "increment<click>"() {
362
+ useCountStore.getState().increment();
363
+ },
364
+ });
365
+ ```
427
366
 
428
- // assign() pulls the latest store + State values into this.updater.
429
- // Always call snapshot() at the top and return altered() at the end
430
- // so the framework knows whether a re-digest is needed.
431
- assign() {
432
- this.updater.snapshot();
367
+ Custom subscription callback (when data transformation is needed before sync):
433
368
 
434
- const store = useCountStore();
435
- this.updater.set({
436
- title: "Home",
437
- count: store.count,
438
- step: store.step,
439
- items: [
440
- { id: "a", name: "Alpha" },
441
- { id: "b", name: "Beta" },
442
- ],
443
- });
444
-
445
- return this.updater.altered();
446
- },
369
+ ```ts
370
+ init() {
371
+ const syncToView = () => {
372
+ const s = useCountStore.getState();
373
+ this.updater.digest({ count: s.count, isPositive: s.count > 0 });
374
+ };
375
+ const off = useCountStore.subscribe(syncToView);
376
+ this.on("destroy", off);
377
+ syncToView();
378
+ }
379
+ ```
447
380
 
448
- // render() is wrapped by the framework to manage signature/lifecycle.
449
- // The default implementation calls this.updater.digest().
450
- render() {
451
- this.updater.digest();
452
- },
381
+ Destroying a store:
453
382
 
454
- // Event method naming: `name<eventType>`. See "Event methods" below.
455
- "navigateTo<click>"(e: Record<string, unknown>) {
456
- const params = e["params"] as Record<string, string> | undefined;
457
- if (params?.path) Router.to(params.path);
458
- },
459
- });
383
+ ```ts
384
+ useCountStore.destroy(); // Clears all listeners, removes from registry
460
385
  ```
461
386
 
462
- ### Event methods
387
+ ### Comparison
463
388
 
464
- Lark scans the View prototype once per class (in `View.prepare`) and builds three event maps on the prototype (`$evtObjMap`, `$selMap`, `$globalEvtList`). DOM events are delegated to `document.body` using **capture-phase** listeners with reference counting — the first binding installs the listener, the last unbinding removes it.
389
+ | Dimension | State | Store |
390
+ |-----------|-------|-------|
391
+ | Write | `State.set(...)` + `State.digest()` | `store.setState(partial)` or action |
392
+ | Read | `State.get(key)` | `store.getState()` |
393
+ | Subscribe | `observeState` or `on("changed")` | `store.subscribe(listener)` or `bindStore` |
394
+ | View binding | `observeState("keys")` | `bindStore(view, store, selector?)` |
395
+ | Lifecycle | `State.clean` mixin auto-reclaims keys | `store.destroy()` manual teardown |
396
+ | Derived data | Not supported | `computed(deps, fn)` |
397
+ | Use case | Page title, login state, theme | Business entities, forms, complex cross-view state |
465
398
 
466
- | Pattern | Meaning |
467
- | -------------------------- | ------------------------------------------------- |
468
- | `handler<click>` | Root event on the view element |
469
- | `$selector<click>` | Delegated event matching CSS selector `.selector` |
470
- | `$<click>` | Empty selector — frame boundary event only |
471
- | `$window<resize>` | Global event on `window` |
472
- | `$document<keydown>` | Global event on `document` |
473
- | `handler<click,mousedown>` | Multi-event binding |
474
- | `name<click><ctrl>` | Modifier filter — only fires when Ctrl is pressed |
399
+ Selection guide: start with State; upgrade to Store when you need actions, derived data, or fine-grained subscriptions; view-private data always goes through Updater.
475
400
 
476
- Each event handler receives an event object with these augmented fields:
401
+ ## View Definition and Lifecycle
477
402
 
478
- - `e.eventTarget` the actual DOM element that was clicked.
479
- - `e.params` — parsed parameters from `@event` attributes (URL query string format).
480
- - All standard DOM Event properties (`type`, `target`, etc.).
403
+ ### Two Definition Approaches
481
404
 
482
- When two mixins define the same event method, they're merged into a single function that calls both in sequence via a `handlerList` array.
405
+ `View.extend({...})` is the low-level primitive approach where all mixins, event methods, and lifecycle hooks are declared in the passed object:
483
406
 
484
- ### Resource management
407
+ ```ts
408
+ import { View } from "@lark.js/mvc";
485
409
 
486
- `capture` and `release` manage objects whose lifetime tracks the view (timers, services, observers, etc.):
410
+ export default View.extend({
411
+ template,
412
+ init() { /* ... */ },
413
+ assign() { /* ... */ },
414
+ render() { /* ... */ },
415
+ });
416
+ ```
417
+
418
+ `defineView({...})` is a typed wrapper that threads the literal's own fields into `this` via `ThisType<P & ViewInterface>`:
487
419
 
488
420
  ```ts
489
- const timer = setInterval(() => {
490
- /* ... */
491
- }, 1000);
492
- this.capture(
493
- "myTimer",
494
- {
495
- destroy() {
496
- clearInterval(timer);
497
- },
421
+ import { defineView } from "@lark.js/mvc";
422
+
423
+ export default defineView({
424
+ customField: "x",
425
+ init() {
426
+ console.log(this.customField);
498
427
  },
499
- true,
500
- );
501
- // destroyOnRender=true: destroyed on next render call
502
- // destroyOnRender=false: destroyed only on view destroy
428
+ });
503
429
  ```
504
430
 
505
- `release(key, destroy=true)` removes the entry (and calls `.destroy()` unless `destroy=false`).
431
+ Both produce equivalent runtime artifacts; the difference is purely in TypeScript inference.
506
432
 
507
- ### Async safety with `wrapAsync`
433
+ ### Lifecycle
508
434
 
509
- Async callbacks may resolve after the view has been re-rendered or destroyed. `wrapAsync` captures the current signature so the callback short-circuits if the view has moved on:
435
+ - `init(params?)` Called when the view is first instantiated. `params` comes from query strings on `v-lark`. Read stores and call `this.assign()` to prepare initial data here.
436
+ - `make()` — Called by the merged ctors pipeline; each mixin's `make` executes in order. Suitable for "run once per instance" initialization.
437
+ - `assign()` — Should be called when data may have changed. Pattern: `this.updater.snapshot()` at the top, `this.updater.set(...)` in the middle, `return this.updater.altered()` at the end. The framework uses `altered()` to determine whether re-render is needed.
438
+ - `render()` — Default implementation is `this.updater.digest()`. Wrapped by `View.wrapMethod`: increments signature on entry, handles pending endUpdate cleanup on exit.
439
+ - Destruction — The framework automatically calls `release(key, true)` to release all `capture`d resources, cleans up event delegation, and sets signature to 0.
510
440
 
511
- ```ts
512
- async loadData() {
513
- const safeCallback = this.wrapAsync((data) => {
514
- this.updater.set({ items: data }).digest();
515
- });
516
- const data = await fetch("/api/items").then(r => r.json());
517
- safeCallback(data); // no-op if view re-rendered or destroyed
518
- }
519
- ```
441
+ `view.signature` marks async operation validity: greater than 0 means the view is alive (incremented on each render), 0 means destroyed. Never modify it manually.
520
442
 
521
- ### Location observation
443
+ ### Event Methods
522
444
 
523
- ```ts
524
- // In a view:
525
- this.observeLocation("page,size", true); // params + observePath
526
- this.observeLocation(["page", "size"]); // array form
527
- this.observeLocation({ params: ["page"], path: true }); // object form
528
- ```
445
+ Event methods are named `name<eventType>` or `$selector<eventType>`. `View.prepare` scans the prototype at class definition time, parsing methods into three maps (`$evtObjMap` / `$selMap` / `$globalEvtList`) written to the prototype, managed at runtime by `EventDelegator`.
529
446
 
530
- When the listed params or path change, the framework re-runs the view's render automatically.
447
+ | Syntax | Meaning |
448
+ |--------|---------|
449
+ | `handler<click>` | Event on the view's root element |
450
+ | `$selector<click>` | Delegated to child elements matching `.selector` |
451
+ | `$<click>` | Empty selector, triggers Frame boundary event only |
452
+ | `$window<resize>` | Delegated to `window` |
453
+ | `$document<keydown>` | Delegated to `document` |
454
+ | `handler<click,mousedown>` | Multi-event binding |
455
+ | `name<click><ctrl>` | Fires only when Ctrl modifier is held |
531
456
 
532
- ### State observation (for the State pipeline)
457
+ The event callback receives an object `e` that, beyond standard Event fields, provides `e.eventTarget` (the actual hit DOM element) and `e.params` (parsed from the `@event` parameter string). Multiple mixins defining the same event method name are merged into a handler chain called in mixin order.
533
458
 
534
- ```ts
535
- this.observeState("count,step"); // comma-separated
536
- this.observeState(["count", "step"]); // array
537
- ```
459
+ Event delegation implementation: `EventDelegator` attaches listeners on `document.body` in the capture phase. When an event fires, it walks from `e.target` up to body, calling `findFrameInfo` at each level to locate the owning View and filter handlers by selector. Reference counting manages addition/removal of same-name events on body to prevent duplicate binding or premature unbinding.
538
460
 
539
- When State.digest() flips one of these keys, the framework re-renders the view.
461
+ ### Resource Management
540
462
 
541
- ### Sub-view embedding
463
+ `capture` registers "destroyable objects tied to the view lifecycle":
542
464
 
543
- ```html
544
- <div v-lark="components/child-view"></div>
465
+ ```ts
466
+ const timer = setInterval(tick, 1000);
467
+ this.capture("myTimer", { destroy() { clearInterval(timer); } }, true);
545
468
  ```
546
469
 
547
- At mount time, `Frame.mountZone` runs `querySelectorAll("[v-lark]")` on the view's root, creates a child Frame for each match, and mounts the registered View class. The container's inner content is replaced by the child view's rendered output.
470
+ The third parameter `destroyOnRender` when `true` causes automatic destruction and removal on the next render call; when `false` cleanup happens only on view destruction. `release(key, destroy = true)` manually removes an entry.
548
471
 
549
- For dynamic loading (no upfront `registerViewClass`), `mountView` automatically calls `Framework.use()` to load the View class through the configured `require` hook (see Module Federation below).
472
+ ### Async Safety
550
473
 
551
- ## Defining the Framework Boot
552
-
553
- `Framework.boot(config)` config accepts:
474
+ Async callbacks may arrive after a view has re-rendered or been destroyed. `wrapAsync` adds a signature check layer:
554
475
 
555
476
  ```ts
556
- interface FrameworkConfig {
557
- rootId: string; // required DOM id for the root frame
558
- defaultView?: string; // default view path
559
- defaultPath?: string; // path when URL hash is empty (defaults to "/")
560
- routes?: Record<string, string | RouteViewConfig>; // path → view path mapping
561
- hashbang?: string; // defaults to "#!"
562
- unmatchedView?: string; // 404 view path
563
- rewrite?: (path, params, routes) => string; // dynamic path rewriting
564
- error?: (e: Error) => void; // global error handler (do not re-throw)
565
- extensions?: string[]; // extension view paths loaded at startup
566
- initModule?: string; // init module path
567
- skipViewRendered?: boolean;
568
- projectName?: string; // for Module Federation discriminator
569
- crossConfigs?: CrossSiteConfig[]; // MF remote configs
570
- require?: (names: string[], params?) => Promise<unknown[]>; // async View loader
571
- [k: string]: unknown; // custom keys are allowed
477
+ async loadData() {
478
+ const safe = this.wrapAsync((data: unknown) => {
479
+ this.updater.set({ items: data }).digest();
480
+ });
481
+ const data = await fetch("/api/items").then((r) => r.json());
482
+ safe(data); // Will not execute if view has re-rendered or been destroyed
572
483
  }
573
484
  ```
574
485
 
575
- After boot, prefer `Framework.getConfig(key)` for reads and `Framework.setConfig(patch)` for writes. The older `Framework.config(...)` overload still works but is `@deprecated`.
486
+ `mark(host, key)` / `unmark(host)` is the lower-level equivalent mechanism: returns a `() => boolean` validator. All mark state is stored in a module-level `WeakMap` rather than polluting the host object, so it works on `Object.freeze`d objects.
487
+
488
+ ## Router and Route Guards
576
489
 
577
- ## Router
490
+ Router supports two routing modes, configured via `FrameworkConfig.routeMode`:
578
491
 
579
- ### Navigation
492
+ - `"history"` (default): uses `history.pushState` / `popstate`, URLs like `/home?page=2`
493
+ - `"hash"`: uses URL hash fragment, URLs like `#!/home?page=2`
494
+
495
+ All state parses into a single `Location` object; cache hits skip parsing.
496
+
497
+ ### Basic Usage
580
498
 
581
499
  ```ts
582
- Router.to("/list", { page: 2 }); // path + params
583
- Router.to({ page: 3 }); // params-only — keeps current path
584
- Router.to("/list", { page: 2 }, true); // replace (no history entry)
585
- Router.to("/list", { page: 2 }, false, true); // silent (no events)
586
- ```
500
+ import { Router } from "@lark.js/mvc";
587
501
 
588
- ### Parsing
502
+ Router.to("/list", { page: 2 }); // path + params
503
+ Router.to({ page: 3 }); // params only
504
+ Router.to("/list", { page: 2 }, true); // replace mode
505
+ Router.to("/list", { page: 2 }, false, true); // silent, no events
506
+ ```
589
507
 
590
508
  ```ts
591
- const loc = Router.parse(); // current location
592
- const loc = Router.parse("https://x.com/?a=1#!/path?p=v");
593
- // loc.path, loc.params, loc.hash, loc.query, loc.view, loc.get("key", "default")
594
- const diff = Router.diff(); // last LocationDiff or undefined
509
+ const loc = Router.parse(); // current Location
510
+ const loc2 = Router.parse("https://x/?a=1#!/path?p=v");
511
+ const diff = Router.diff(); // most recent LocationDiff
595
512
  ```
596
513
 
597
- ### Two-phase change events (existing API)
514
+ `Location` provides `path` / `params` / `hash` / `query` / `view` and a `get(key, defaultValue?)` method.
515
+
516
+ ### Two-Phase Change Event
598
517
 
599
518
  ```ts
600
519
  Router.on("change", (e) => {
601
- if (hasUnsavedChanges)
602
- e.prevent(); // pause subsequent processing
603
- else if (mustReject)
604
- e.reject(); // revert URL to lastHash
605
- else e.resolve(); // commit (auto if neither called)
520
+ if (hasUnsavedChanges) e.prevent();
521
+ else if (mustReject) e.reject();
522
+ else e.resolve();
606
523
  });
607
524
  Router.on("changed", (diff) => {
608
- // diff is a LocationDiff: { params, path?, view?, force, changed }
525
+ // diff: LocationDiff { params, path?, view?, force, changed }
609
526
  });
610
527
  ```
611
528
 
612
- ### Async route guards (`Router.beforeEach`)
529
+ The `change` phase allows `prevent` (suspend further processing), `reject` (rollback URL to `lastHash`), or `resolve` (commit; if none is called explicitly, resolve is the default). The `changed` phase is the final notification where the framework re-mounts views.
530
+
531
+ ### Async Route Guards
613
532
 
614
533
  ```ts
615
534
  const off = Router.beforeEach(async (to, from) => {
616
535
  if (to.path === "/admin") {
617
536
  const ok = await checkPermission();
618
- return ok; // false → revert URL, throw → also reverts
537
+ return ok;
619
538
  }
620
- return true; // or undefined — permits navigation
539
+ return true;
540
+ });
541
+ // Unregister
542
+ off();
543
+ ```
544
+
545
+ Guards execute in registration order. Any guard that returns/resolves to `false`, throws, or rejects will abort the navigation and rollback the URL. Returning `true` / `undefined` / any non-`false` value allows passage.
546
+
547
+ ### useUrlState: URL Parameter State Sync
548
+
549
+ `useUrlState(view, initialState?)` reads URL query parameters into a state object and provides a `setState` function that writes changes back to the URL (via `Router.to()`). It automatically observes the specified parameter keys, re-rendering the view when the URL changes.
550
+
551
+ ```ts
552
+ import { useUrlState } from "@lark.js/mvc";
553
+
554
+ export default View.extend({
555
+ template,
556
+ init() {
557
+ const [state, setState] = useUrlState(this, { page: "1", size: "20" });
558
+ this.updater.set({ page: state.page, size: state.size }).digest();
559
+ this.setPageState = setState;
560
+ },
561
+ "nextPage<click>"() {
562
+ this.setPageState((prev) => ({ page: String(Number(prev.page) + 1) }));
563
+ },
621
564
  });
622
- // later, off() to unsubscribe
623
565
  ```
624
566
 
625
- Guards run in registration order. Any guard that returns/resolves to `false`, throws, or rejects aborts the navigation and reverts the URL. Returning `true`, `undefined`, or any non-`false` value permits it.
567
+ Supports both history and hash routing modes.
568
+
569
+ ## Service Request Layer
626
570
 
627
- ## Service (API requests)
571
+ `Service` is a request management layer built on `fetch` (or any synchronous function) with built-in LFU caching, concurrent deduplication, serial queuing, and lifecycle events.
628
572
 
629
- `Service` is an opinionated layer around `fetch` (or any sync function) with LFU caching, in-flight deduplication, serial task queueing, and lifecycle events.
573
+ ### Defining Subclasses and Endpoints
630
574
 
631
575
  ```ts
632
576
  import { Service, type Payload } from "@lark.js/mvc";
@@ -636,19 +580,14 @@ const AppService = Service.extend(
636
580
  fetch(payload.get<string>("url"), {
637
581
  method: payload.get<string>("method") || "GET",
638
582
  headers: { "Content-Type": "application/json" },
639
- body: payload.get("data")
640
- ? JSON.stringify(payload.get("data"))
641
- : undefined,
583
+ body: payload.get("data") ? JSON.stringify(payload.get("data")) : undefined,
642
584
  })
643
585
  .then((r) => r.json())
644
- .then((data) => {
645
- payload.set(data);
646
- callback();
647
- })
586
+ .then((data) => { payload.set(data); callback(); })
648
587
  .catch(() => callback());
649
588
  },
650
589
  20, // cacheMax
651
- 5, // cacheBuffer
590
+ 5, // cacheBuffer
652
591
  );
653
592
 
654
593
  AppService.add([
@@ -658,94 +597,83 @@ AppService.add([
658
597
  url: "/api/users/:id",
659
598
  cache: 30_000,
660
599
  before(payload) {
661
- payload.set(
662
- "url",
663
- payload.get<string>("url").replace(":id", payload.get<string>("id")),
664
- );
600
+ payload.set("url", payload.get<string>("url").replace(":id", payload.get<string>("id")));
665
601
  },
666
602
  after(payload) {
667
603
  const data = payload.get("data");
668
604
  payload.set({ formatted: formatUser(data) });
669
605
  },
670
- cleanKeys: "userList", // invalidate userList cache when this completes
606
+ cleanKeys: "userList",
671
607
  },
672
608
  ]);
673
609
  ```
674
610
 
675
- ### Per-subclass isolation
676
-
677
- `Service.extend()` produces a subclass with its own `_metaList`, `_payloadCache`, `_pendingCacheKeys`, `_syncFn`, `_staticEmitter`, `_cacheMax`, and `_cacheBuffer` via `static override`. This isolation is intentional — endpoints registered on one subclass never leak into another. Refactoring these `static override` declarations away would break the isolation.
678
-
679
- ### Using a service in a view
611
+ ### Using in Views
680
612
 
681
613
  ```ts
682
614
  export default View.extend({
683
615
  template,
684
616
  init() {
685
617
  const service = new AppService();
686
- this.capture("userService", service, true); // auto-destroy on render
618
+ this.capture("userService", service, true);
687
619
  this.service = service;
688
620
  this.loadData();
689
621
  },
690
622
  loadData() {
691
- this.service.all("userList", (errors, userListPayload) => {
692
- if (!errors[0]) {
693
- this.updater.set({ users: userListPayload.get("data") }).digest();
694
- }
695
- });
696
- },
697
- refreshDetail(id: string) {
698
- // save() bypasses cache, always makes a real request.
699
- this.service.save({ name: "userDetail", id }, (errors, payload) => {
623
+ this.service.all("userList", (errors, payload) => {
700
624
  if (!errors[0]) {
701
- this.updater.set({ detail: payload.get("data") }).digest();
625
+ this.updater.set({ users: payload.get("data") }).digest();
702
626
  }
703
627
  });
704
628
  },
705
629
  });
706
630
  ```
707
631
 
708
- ### Service method summary
632
+ | Method | Behavior |
633
+ |--------|----------|
634
+ | `service.all(attrs, done)` | Fetch all endpoints; callback `(errors, p1, p2, ...)` when all complete |
635
+ | `service.one(attrs, done)` | Fetch all endpoints; callback `(error, payload, isLast, index)` on each completion |
636
+ | `service.save(attrs, done)` | Same as `all` but skips cache, always makes a fresh request |
637
+ | `service.enqueue(task)` | Add to serial queue |
638
+ | `service.dequeue(...args)` | Take one item and execute |
639
+ | `service.destroy()` | Destroy instance and cancel pending callbacks |
709
640
 
710
- | Method | Behavior |
711
- | --------------------------- | ----------------------------------------------------------------------------- |
712
- | `service.all(attrs, done)` | Fetch all endpoints; callback with `(errors, p1, p2, ...)` when ALL complete |
713
- | `service.one(attrs, done)` | Fetch all endpoints; callback PER endpoint: `(error, payload, isLast, index)` |
714
- | `service.save(attrs, done)` | Like `all` but always skips cache (force refresh) |
715
- | `service.enqueue(task)` | Queue a task for serial execution |
716
- | `service.dequeue(...args)` | Pop and run the next queued task |
717
- | `service.destroy()` | Destroy instance; cancel further callbacks |
641
+ ### Caching and Deduplication
718
642
 
719
- ### Caching and dedup
643
+ `Cache` implements an LFU-style bounded cache: sorted by `(frequency, lastTimestamp)`, evicting `bufferSize` entries via single-pass partial selection (O(n*k), k typically 5) when capacity exceeds `maxSize + bufferSize`. `del` immediately removes from the `entries` array and `lookup` Map.
720
644
 
721
- The `Cache` class is an LFU cache with single-pass partial-selection eviction (O(n·k)) — see `cache.ts`. The pending-key map (`pendingCacheKeys`) deduplicates concurrent requests for the same `(endpoint, params)`. All pending callbacks are queued and called when the single in-flight request completes.
645
+ `_pendingCacheKeys` tracks in-flight requests per `(endpoint, params)` key. Concurrent calls to the same key are added to a callback chain; a single request completes and invokes all callbacks, avoiding redundant network round-trips.
722
646
 
723
- `defaultCacheKey` memoizes `JSON.stringify(meta)` per `ServiceMetaEntry` via `WeakMap`meta entries are immutable after `Service.add()`.
647
+ `cleanKeys: "userList"` means the current endpoint, upon completion, clears the corresponding cache entry commonly used to invalidate list queries after a write operation.
724
648
 
725
- ## Templates
649
+ ## Template Syntax
726
650
 
727
- ### Operators
651
+ Template files use the `.html` extension and are compiled at build time by `larkMvcPlugin` / `larkMvcLoader` into ES modules exporting a `(data, viewId, refData) => string` render function.
728
652
 
729
- | Operator | Syntax | Meaning |
730
- | -------- | --------------- | ------------------------------------------------------------------ |
731
- | `=` | `{{=variable}}` | HTML-escaped output (`&`, `<`, `>`, `"`, `'`, backtick) |
732
- | `!` | `{{!variable}}` | Raw output (no escaping). Use with care for user-generated content |
733
- | `@` | `{{@variable}}` | Reference lookup — stores a JS value in refData, emits a token |
734
- | `:` | `{{:variable}}` | Two-way binding marker (renders identically to `=`) |
653
+ ### Expression Operators
735
654
 
736
- ### Control flow
655
+ | Syntax | Meaning |
656
+ |--------|---------|
657
+ | `{{=variable}}` | HTML-escaped output (escapes `& < > " ' \``) |
658
+ | `{{!variable}}` | Raw output, use with caution (potential XSS) |
659
+ | `{{@variable}}` | Reference lookup: stores the JS value in refData and produces a token, used with `@event` to pass live references |
660
+ | `{{:variable}}` | Two-way binding marker; renders equivalently to `=` |
661
+
662
+ ### Control Flow
737
663
 
738
664
  ```html
739
- {{if condition}}...{{else if other}}...{{else}}...{{/if}} {{forOf list as item}}
740
- ... {{/forOf}} {{forOf list as item idx}} {{=idx}}: {{=item.name}} {{/forOf}}
741
- {{forOf list as {name, age} idx last first}} ... {{/forOf}} {{forIn object as
742
- value key}} ... {{/forIn}} {{for (let i = 0; i < n; i++)}} ... {{/for}} {{set
743
- localVar = expr}}
665
+ {{if condition}}...{{else if other}}...{{else}}...{{/if}}
666
+ {{forOf list as item}} ... {{/forOf}}
667
+ {{forOf list as item idx}} {{=idx}}: {{=item.name}} {{/forOf}}
668
+ {{forOf list as {name, age} idx last first}} ... {{/forOf}}
669
+ {{forIn object as value key}} ... {{/forIn}}
670
+ {{for (let i = 0; i < n; i++)}} ... {{/for}}
671
+ {{set localVar = expr}}
744
672
  ```
745
673
 
746
- `forOf` REQUIRES the `as` keyword: `{{forOf list as item}}` is correct; `{{forOf list item}}` is a compile-time error.
674
+ `forOf` requires the `as` keyword. `{{forOf list item}}` is a compile-time error; the correct form is `{{forOf list as item}}`.
747
675
 
748
- ### Event binding
676
+ ### Event Binding
749
677
 
750
678
  ```html
751
679
  <button @click="handlerName({key: 'value', other: 123})">Go</button>
@@ -753,80 +681,91 @@ localVar = expr}}
753
681
  <form @submit.prevent="onSubmit()">...</form>
754
682
  ```
755
683
 
756
- The compiler converts JS object literal params (`{a: 1}`) into URL query format (`a=1`) so they can survive transport through DOM attributes. It also injects the current view's `$viewId` and a SPLITTER separator so the EventDelegator can route correctly across nested frames.
684
+ The compiler converts JS object literal parameters (`{a:1}`) to URL query string format (`a=1`) for transmission through DOM attributes, and injects the current view's `$viewId` with SPLITTER delimiters into the attribute so that EventDelegator routes events to the correct view and method across nested Frame boundaries.
757
685
 
758
- ### Sub-view embedding
686
+ ### Child View Embedding
759
687
 
760
688
  ```html
761
689
  <div v-lark="components/child"></div>
762
690
  <div v-lark="components/child?title=hello&id=42"></div>
763
691
  <div v-lark="remote-app/views/home"></div>
764
- <!-- Module Federation -->
765
692
  ```
766
693
 
767
- When `v-lark` carries a query string, the params are translated into the child view's `init` arguments. If the value contains a SPLITTER reference, `translateData` resolves it via the parent view's refData before the child mounts.
694
+ With query strings, parameters are translated into the first argument of the child view's `init`. When containing SPLITTER reference tokens, `translateData` resolves original JS values from the parent view's refData before passing them to the child.
768
695
 
769
- ### VDOM optimization hints
696
+ ### VDOM Optimization Hints
770
697
 
771
- | Attribute | Effect |
772
- | --------- | --------------------------------------------------------------------- |
773
- | `ldk` | "Diff key" if old and new have the same `ldk`, skip the entire diff |
774
- | `lak` | "Attribute key" skip attribute diff but still diff children |
775
- | `lvk` | "View key" assign-optimization marker |
698
+ | Attribute | Purpose |
699
+ |-----------|---------|
700
+ | `ldk` | diff key: when old and new ldk match, the entire subtree's diff is skipped |
701
+ | `lak` | attribute key: skips attribute diff but children continue to diff |
702
+ | `lvk` | view key: assign optimization marker |
776
703
 
777
- Mark large static subtrees with `ldk` to skip rendering work entirely.
704
+ Marking large static subtrees with `ldk` can completely skip rendering work. This is currently the framework's only "fine-grained skip diff" mechanism; the compiler does not automatically mark fully static subtrees.
778
705
 
779
- ## Module Federation (micro-frontend)
706
+ ## Frame and the View Tree
780
707
 
781
- ### Pattern 1 Direct async loading
708
+ `Frame` manages view mounting and unmounting, maintains parent-child relationships, and provides cross-view method invocation. Each Frame corresponds to one DOM container and one View instance.
782
709
 
783
- Configure `FrameworkConfig.require` to resolve unknown view paths through MF:
710
+ ### Typed API
784
711
 
785
- ```ts
786
- declare const __webpack_init_sharing__: (name: string) => Promise<void>;
787
- declare const __webpack_share_scopes__: Record<string, Record<string, unknown>>;
712
+ | API | Description |
713
+ |-----|-------------|
714
+ | `Frame.get(id)` | Look up Frame by DOM id |
715
+ | `Frame.getAll()` | All Frames as `Map<string, Frame>` |
716
+ | `Frame.getRoot()` | Current root Frame; returns `undefined` if not created |
717
+ | `Frame.createRoot(id)` | Idempotent root creation (`Framework.boot` calls this) |
718
+ | `Frame.root(id)` | `@deprecated` alias, forwards to `createRoot` |
719
+ | `new Frame(containerId)` | Independent Frame instance for micro-frontend / embedded widget scenarios |
720
+ | `frame.invoke(name, args?)` | Call the owning view's method; if view not mounted, pushes to `invokeList`, flushed by `View.runInvokes(frame)` after mounting |
721
+ | `frame.children()` | Child Frame id array (order not guaranteed) |
722
+ | `frame.parent(level?)` | Ancestor Frame, defaults to one level up |
723
+ | `frame.mountFrame(id, viewPath, params?)` | Explicitly create a child Frame |
724
+ | `frame.unmountFrame(id)` | Unmount a specific child Frame |
725
+ | `frame.mountZone(id?)` / `frame.unmountZone(id?)` | Batch mount/unmount all `v-lark` child nodes in a zone |
726
+ | `Frame.on("add" \| "remove", handler)` | Frame instance lifecycle events (static emitter) |
727
+ | `frame.on("created" \| "alter", handler)` | All child Frames rendered / child content changed (instance emitter) |
788
728
 
729
+ Frame instances enter `frameCache` object pool upon destruction, caching up to `MAX_FRAME_POOL = 64`; beyond that threshold they are GC'd. Do not retain Frame references after unmounting as the object may be reused.
730
+
731
+ ## Module Federation Micro-Frontend
732
+
733
+ Lark treats Module Federation as a first-class citizen, providing two integration modes.
734
+
735
+ ### Mode 1: Direct Async Loading
736
+
737
+ Via `FrameworkConfig.require`, resolve unregistered view paths to remote modules:
738
+
739
+ ```ts
789
740
  Framework.boot({
790
741
  rootId: "app",
791
742
  projectName: "host-app",
792
743
  crossConfigs: [
793
- {
794
- projectName: "remote-app",
795
- source: "remote_app@//cdn.example.com/remote-app/remoteEntry.js",
796
- },
744
+ { projectName: "remote-app", source: "remote_app@//cdn.example.com/remote-app/remoteEntry.js" },
797
745
  ],
798
746
  require: async (names: string[]) => {
799
747
  await __webpack_init_sharing__("default");
800
748
  const container = __webpack_share_scopes__["default"];
801
- return Promise.all(
802
- names.map(async (name) => {
803
- const slash = name.indexOf("/");
804
- const remote = slash > -1 ? name.substring(0, slash) : name;
805
- const mod = slash > -1 ? name.substring(slash + 1) : "./index";
806
- const rc = (window as Record<string, unknown>)[remote] as
807
- | {
808
- init: (s: Record<string, unknown>) => Promise<void>;
809
- get: (m: string) => Promise<() => unknown>;
810
- }
811
- | undefined;
812
- if (!rc) return undefined;
813
- await rc.init(container);
814
- const factory = await rc.get(`./${mod}`);
815
- const raw = factory();
816
- return raw && (raw as Record<string, unknown>).__esModule
817
- ? (raw as Record<string, unknown>).default
818
- : raw;
819
- }),
820
- );
749
+ return Promise.all(names.map(async (name) => {
750
+ const slash = name.indexOf("/");
751
+ const remote = slash > -1 ? name.substring(0, slash) : name;
752
+ const mod = slash > -1 ? name.substring(slash + 1) : "./index";
753
+ const rc = (window as Record<string, unknown>)[remote];
754
+ if (!rc) return undefined;
755
+ await rc.init(container);
756
+ const factory = await rc.get(`./${mod}`);
757
+ const raw = factory();
758
+ return raw && raw.__esModule ? raw.default : raw;
759
+ }));
821
760
  },
822
761
  });
823
762
  ```
824
763
 
825
- Then `v-lark="remote-app/views/home"` just works.
764
+ Then write `v-lark="remote-app/views/home"` in templates to trigger async loading and mounting of the remote view.
826
765
 
827
- ### Pattern 2 CrossSite bridge view (with skeleton + prepare)
766
+ ### Mode 2: CrossSite Bridge View
828
767
 
829
- For richer scenarios that want a loading skeleton and a `prepare` hook on the remote side:
768
+ For skeleton screens and remote `prepare` hooks, use `CrossSite`:
830
769
 
831
770
  ```ts
832
771
  import { CrossSite, registerViewClass } from "@lark.js/mvc";
@@ -837,30 +776,18 @@ registerViewClass("cross-site", CrossSite);
837
776
  <div v-lark="cross-site?view=remote-app/views/home&bizCode=mybiz"></div>
838
777
  ```
839
778
 
840
- CrossSite renders a skeleton container `mf_${viewId}` first, then loads the remote project's `prepare` module via `loadRemoteView()`, then mounts the actual view. Race condition is guarded by `$sign`: if the user navigates away during the async load, the stale mount is aborted. When the remote view path matches the previous one and the existing view supports `assign()`, CrossSite updates in place instead of re-mounting.
779
+ CrossSite first renders as a normal view showing a skeleton (default `Loading...`, overridable via `skeleton` parameter) and occupies a `<div id="mf_${viewId}">` sub-container. `updateView()` uses `++this.$sign` to get a sequence number, loads the remote prepare module via `use(projectName/prepare)` and executes it. Race guard: if after loading `this.$sign !== sign`, return immediately (user navigated away). If the same view path as last time and the remote view exposes an `assign` method, it calls `assign` + `render` in-place to reuse the existing view; otherwise it calls `owner.mountFrame('mf_' + this.id, this.$view, this.$params)` to actually mount.
841
780
 
842
- `CrossSite.callView(name, ...args)` invokes a method on the embedded remote view via `Frame.invoke()`.
843
-
844
- ### Module Federation Webpack config
781
+ ### Webpack Configuration
845
782
 
846
783
  Host:
847
784
 
848
785
  ```js
849
- import { ModuleFederationPlugin } from "webpack/container";
850
-
851
- export default {
852
- plugins: [
853
- new ModuleFederationPlugin({
854
- name: "host_app",
855
- remotes: {
856
- "remote-app": "remote_app@//cdn.example.com/remote-app/remoteEntry.js",
857
- },
858
- shared: {
859
- "@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" },
860
- },
861
- }),
862
- ],
863
- };
786
+ new ModuleFederationPlugin({
787
+ name: "host_app",
788
+ remotes: { "remote-app": "remote_app@//cdn.example.com/remote-app/remoteEntry.js" },
789
+ shared: { "@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" } },
790
+ });
864
791
  ```
865
792
 
866
793
  Remote:
@@ -869,222 +796,182 @@ Remote:
869
796
  new ModuleFederationPlugin({
870
797
  name: "remote_app",
871
798
  filename: "remoteEntry.js",
872
- exposes: {
873
- "./views/home": "./src/views/home",
874
- "./prepare": "./src/prepare",
875
- },
876
- shared: {
877
- "@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" },
878
- },
799
+ exposes: { "./views/home": "./src/views/home", "./prepare": "./src/prepare" },
800
+ shared: { "@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" } },
879
801
  });
880
802
  ```
881
803
 
882
- `@lark.js/mvc` MUST be shared as `singleton: true` so host and remote use the same View/Frame class instances `instanceof` checks fail across boundaries otherwise.
883
-
884
- ### `splitChunks.chunks` must be `"async"` in MF projects
885
-
886
- | Value | Splits initial chunks | Splits async chunks |
887
- | ----------- | --------------------- | ------------------- |
888
- | `"initial"` | yes | no |
889
- | `"async"` | no | yes |
890
- | `"all"` | yes | yes |
891
-
892
- If `chunks: "all"` extracts `@lark.js/mvc` into a separate vendor chunk, the MF shared scope initialization fails — `remoteEntry.js` needs `@lark.js/mvc` synchronously available in the initial entry chunk. With `chunks: "async"`, shared singletons stay in the entry chunk and `window.<remote_name>` is set correctly.
893
-
894
- ### `Frame.root()` vs `new Frame()` for MF
804
+ `@lark.js/mvc` must be `singleton: true`; otherwise host and remote hold different View/Frame class instances and all `instanceof` checks fail across boundaries.
805
+
806
+ `splitChunks.chunks` must be `"async"`. Using `"all"` extracts `@lark.js/mvc` into a separate vendor chunk, breaking MF shared scope initialization (`ScriptExternalLoadError: Loading script failed`).
807
+
808
+ ## Debugging and DevTools Bridge
809
+
810
+ ### Global Objects
811
+
812
+ After `Framework.boot` completes, the following are attached to `window`:
813
+
814
+ | Global | Value | Purpose |
815
+ |--------|-------|---------|
816
+ | `window.__lark_Framework` | Framework object | Direct access |
817
+ | `window.__lark_State` | State object | Direct access |
818
+ | `window.__lark_Router` | Router object | Direct access |
819
+ | `window.__lark_Frame` | Frame class | Direct access |
820
+ | `window.__lark_View` | View class | Direct access |
821
+ | `window.__lark_registerViewClass` | Function | HMR: re-register View class |
822
+ | `window.__lark_invalidateViewClass` | Function | HMR: remove View from registry |
823
+ | `window.__lark_getViewClassRegistry` | Function | HMR: read registry |
824
+ | `window.__lark_Debug` | boolean, must be set manually | Enable Safeguard Proxy debug checks |
825
+
826
+ ### Safeguard Debug Mode
827
+
828
+ Set `window.__lark_Debug = true` before boot, and the framework wraps `State.get()` / `Router.diff()` results and `Updater.get()` return values with Safeguard Proxy:
829
+
830
+ - Warns when reading data written by another page (potential cross-page pollution).
831
+ - Warns immediately when assigning directly to objects returned by `State.get()` (deduplicated by key); the correct approach is `State.set(patch)` + `State.digest()`.
832
+
833
+ ### Frame Visualizer Bridge
834
+
835
+ `installFrameVisualizerBridge()` is automatically installed during `Framework.boot`, listening for `window` message events and communicating with DevTools via postMessage:
836
+
837
+ - `LARK_VIS_PING` — responds with `LARK_VIS_PONG` to confirm this page is a Lark application.
838
+ - `LARK_VIS_REQUEST_TREE` — responds with `LARK_VIS_TREE` carrying `SerializedFrameTree`.
839
+ - Internally listens to `Frame.on('add' | 'remove')` and automatically pushes `LARK_VIS_TREE_DELTA`; JSON.stringify is compared with `lastTreeJson` before pushing to avoid flooding when nothing changed.
840
+
841
+ The `lark-visual` sub-project in this repository is the paired visual DevTools that loads the target application via iframe to display the real-time Frame tree.
842
+
843
+ ## Public API Reference
844
+
845
+ ### Framework
846
+
847
+ - `Framework.boot(config)` — Start the application.
848
+ - `Framework.getConfig()` / `Framework.getConfig(key)` — Read configuration.
849
+ - `Framework.setConfig(patch)` — Merge configuration, returns merged result.
850
+ - `Framework.use(names, callback?)` — Async view loader; returns `Promise<unknown[]>` when no callback.
851
+ - `Framework.mark(host, key)` / `Framework.unmark(host)` — Async callback validity tracking via module-level `WeakMap`.
852
+ - `Framework.dispatch(target, type, init?)` — Trigger custom DOM event.
853
+ - `Framework.task(fn, args?, ctx?)` — Chunked execution: prefers `scheduler.postTask` then `requestIdleCallback` then `setTimeout(0)`, with a fixed 48ms budget or adaptive time slicing.
854
+ - `Framework.delay(ms)` — Promise-wrapped setTimeout.
855
+ - `Framework.waitZoneViewsRendered(viewId, timeout?)` — Wait until all views in a zone have rendered.
856
+ - `Framework.applyStyle(idOrPairs, css?)` — Dynamically inject CSS, returns cleanup function.
857
+
858
+ ### Updater
859
+
860
+ - `updater.get(key?)` — Read data; returns entire data object when no key.
861
+ - `updater.set(data, excludes?)` — Shallow merge and collect changed keys.
862
+ - `updater.digest(data?, excludes?, callback?)` — Render; supports re-entry via `digestingQueue`.
863
+ - `updater.snapshot()` — Record current monotonic version.
864
+ - `updater.altered()` — Check if changed, returns `boolean | undefined`.
865
+ - `updater.translate(value)` — Resolve SPLITTER + number reference tokens to original values.
866
+ - `updater.parse(expr)` — Safe path parser: dot paths (`a.b.c`) or numeric literals only, no eval.
867
+ - `updater.getChangedKeys()` — `ReadonlySet<string>` of keys changed since last digest.
868
+
869
+ ### Store (zustand-style)
870
+
871
+ - `create(name, (set, get) => body)` — Create store, returns `StoreApi`.
872
+ - `store.getState()` — Read current state.
873
+ - `store.setState(partial | updater)` — Shallow merge, notify all listeners.
874
+ - `store.subscribe(listener)` — Listen for changes, returns unsubscribe function.
875
+ - `store.destroy()` — Destroy store, clear listeners.
876
+ - `computed(deps, fn)` — Declare derived state.
877
+ - `bindStore(view, store, selector?)` — Bind to Lark View with auto-sync and auto-cleanup.
878
+ - `useUrlState(view, initialState?)` — URL parameter state sync.
879
+
880
+ ## Common Pitfalls
881
+
882
+ 1. `boot.ts` must be inside `src/`: HTML references `/src/boot.ts`; placing it at the project root causes runtime resolution failure.
883
+ 2. `registerViewClass` must precede `Framework.boot()`: all View classes (including sub-components) must either be pre-registered or loaded via `FrameworkConfig.require`.
884
+ 3. `.html` imports require build integration: only works in projects compiled by `larkMvcPlugin` / `larkMvcLoader`.
885
+ 4. Write State with `State.set` + `State.digest`, never mutate the returned object directly: Safeguard warns in debug mode, deduplicated by key.
886
+ 5. `bindStore` auto-unsubscribes on view destroy; manual `store.subscribe(listener)` calls need explicit cleanup (e.g., `this.on("destroy", off)`).
887
+ 6. Event methods use `<>` not `()`: write `name<click>`, not `name(click)`.
888
+ 7. `assign()` must have `snapshot` at the top and `return altered()` at the bottom: both are required for the framework to determine re-render necessity.
889
+ 8. Never modify `view.signature`: internally managed; 0 means destroyed, render wrapper auto-increments.
890
+ 9. `v-lark` container content is replaced: do not put scaffold text inside.
891
+ 10. Webpack must use `exclude: /index\.html$/`: entry HTML is handled by HtmlWebpackPlugin.
892
+ 11. Webpack loader must be imported as a value: `loader: larkMvcLoader`, not a string name.
893
+ 12. Store state is a plain object: `store.getState()` returns the actual state object; reads are direct access, writes must go through `setState()` or actions.
894
+ 13. `forOf` requires `as`: `{{forOf list item}}` is a compile error.
895
+ 14. `wrapAsync` validates by signature: callback only executes when `view.signature` matches the value at wrap time.
896
+ 15. Frame object pool caps at `MAX_FRAME_POOL = 64`: do not retain Frame references after `unmountFrame`.
897
+ 16. Updater supports digest re-entry: digest during digest enters `digestingQueue`; `null` is the boundary.
898
+ 17. Store creator runs once: state persists across view mount/unmount cycles; call `store.destroy()` to tear down.
899
+ 18. State is simple, Store is complex: lightweight shared values use State; use `create()` for actions, derived data, or fine-grained subscriptions; always pair State writes with `mixins: [State.clean("keys")]` to prevent leaks.
900
+ 19. MF view paths use the remote project name as prefix: `v-lark="remote-app/views/home"` triggers async loading via `FrameworkConfig.require` when unregistered; `@lark.js/mvc` must be `singleton: true`.
901
+ 20. `splitChunks.chunks` must be `"async"` in MF projects: `"all"` breaks shared scope initialization.
902
+
903
+ ## Recent API Changes
904
+
905
+ - Store rewrite (zustand-style):
906
+ - `defineStore(name, (store) => body)` replaced by `create(name, (set, get) => body)`. `defineStore` retained as deprecated alias.
907
+ - `store.key = value` (Proxy write) replaced by `set({ key: value })`.
908
+ - `store.key` reads in actions replaced by `get().key`.
909
+ - `useStore(view)` + `store.observe(view, keys?)` replaced by `bindStore(view, store, selector?)`.
910
+ - `useStore()` (read-only access) replaced by `store.getState()`.
911
+ - `store.observe(undefined, keys, cb)` (internal reaction) replaced by `store.subscribe((state, prev) => ...)`.
912
+ - Removed: `multi()`, `cell()`, `observeCell()`, `cloneStore()`, `getStore()`, `delStore()`, `getUseStore()`, `isStoreActive()`, `createState()`, `shallowSet()`, `lazySet()`, `cloneData()`, `isState()`, `storeMark`, `storeUnmark`, `getPlatform`, `Platform`, `StoreConfig`, `ObservePayload`, `StoreMethods`, `LarkUseStore`, `ReactUseStore`, `NodeUseStore`.
913
+ - Router history mode support:
914
+ - Added `FrameworkConfig.routeMode` (`"history"` default, `"hash"` optional).
915
+ - In history mode, path comes from `window.location.pathname`, params from search query string.
916
+ - Added `useUrlState(view, initialState?)` for URL parameter state sync.
917
+ - `ChangeEvent.keys` changed to `ReadonlySet<string>` (was `Record<string, 1>`). Use `keys.has("foo")` instead of `keys.foo`.
918
+ - `StateInterface.diff()` returns `ReadonlySet<string>`.
919
+ - `Updater.set/digest`, `State.set/digest`, `setData` `excludes?` changed to `ReadonlySet<string>` (was `Set<string>`).
920
+ - `Frame.root(id)` `@deprecated`. Read via `Frame.getRoot()`, create singleton via `Frame.createRoot(id)`, independent mount via `new Frame(id)`.
921
+ - `Updater.parse` no longer evals; only supports safe paths and literals.
922
+ - `mark.ts` no longer writes magic keys to host objects; uses module-level `WeakMap`, works on `Object.freeze`d objects.
923
+ - `Cache.del` immediately removes from `entries` array and `lookup` Map (previously left tombstones until next eviction).
895
924
 
896
- `Frame.root()` (and the newer `Frame.createRoot()`) is a singleton — always returns the first root, ignoring later `id` arguments. For MF containers that need independent rendering contexts, use `new Frame(containerId)` directly so each mount owns its frame tree.
925
+ ## Comparison with Vue 3 / React 19
897
926
 
898
- Use the new APIs (preferred):
927
+ ### vs Vue 3
899
928
 
900
- - `Frame.getRoot()` pure getter, returns `undefined` if not yet created.
901
- - `Frame.createRoot(id)` — idempotent create (Framework.boot calls this).
902
- - `new Frame(containerId)` — independent root for MF or for an embeddable widget.
903
- - `Frame.root(id)` — `@deprecated` alias that delegates to `createRoot`.
929
+ Similarities: templates compile to functions; reactivity via Proxy; derived data with `computed`; microtask batching; component-level granular updates.
904
930
 
905
- ### Exposed mount function pattern
931
+ | Dimension | Vue 3 | Lark |
932
+ |-----------|-------|------|
933
+ | Component abstraction | SFC / function components / Options | Class inheritance `View.extend` / `defineView` |
934
+ | Render output | VNode patch | HTML string parsed to real DOM + diff |
935
+ | Template syntax | `v-if` / `v-for` / `:bind` | `{{if}}` / `{{forOf}}` / `@event` / `v-lark` |
936
+ | Dependency tracking | Automatic effect tracking | subscribe + bindStore + computed |
937
+ | Compile optimizations | PatchFlag / hoistStatic / cacheHandler | Only `ldk` / `lak` / `lvk` user-manual markers |
938
+ | Micro-frontend | Third-party (qiankun / wujie etc.) | Built-in `CrossSite` + `FrameworkConfig.require` |
939
+ | Scheduling | Microtask batching + nextTick | Microtask batching + `Framework.task` sliceable queue |
906
940
 
907
- For React/other-host integrations, expose a mount function rather than raw View classes:
941
+ The key difference is render output: Vue uses virtual node patching; Lark generates HTML strings, parses them via innerHTML into a temporary div, then diffs the resulting real DOM. The advantage is that context-sensitive tags (`<table>` / `<select>` / `<svg>`) are handled by the native parser nearly for free; the disadvantage is the absence of PatchFlag-style compile-time annotations (only user-manual `ldk` / `lak` / `lvk`).
908
942
 
909
- ```ts
910
- // src/exposed/counter-view.ts
911
- import {
912
- Framework,
913
- Frame,
914
- registerViewClass,
915
- EventDelegator,
916
- Router,
917
- State,
918
- } from "@lark.js/mvc";
919
- import CounterView from "../views/counter";
920
- import "../index.css"; // MF remote must explicitly import CSS
921
-
922
- const MF_COUNTER = "mf/counter";
923
- registerViewClass(MF_COUNTER, CounterView);
924
-
925
- export function mountCounter(container: HTMLElement): () => void {
926
- const containerId = container.id || "mf-counter-root";
927
- container.id = containerId;
928
-
929
- Framework.setConfig({ rootId: containerId, error: console.error });
930
- EventDelegator.setFrameGetter((id: string) => Frame.get(id));
931
- Reflect.set(Router, "_booted", true);
932
- Reflect.set(State, "_booted", true);
933
-
934
- const frame = new Frame(containerId); // NOT Frame.createRoot
935
- frame.mountView(MF_COUNTER);
936
-
937
- return () => {
938
- frame.unmountView();
939
- Frame.getAll().delete(containerId);
940
- const el = document.getElementById(containerId);
941
- if (el) Reflect.set(el, "frameBound", 0);
942
- };
943
- }
944
- ```
943
+ ### vs React 19
945
944
 
946
- The MF remote MUST explicitly import its CSS (`import "../index.css"`) Webpack only bundles CSS reachable from the exposed module's import graph.
945
+ Similarities: unidirectional data flow; immutable write-back style; async protection (`wrapAsync` is analogous to `useEffect` cleanup + AbortController); microtask batching; global error boundary (`FrameworkConfig.error` + `funcWithTry`).
947
946
 
948
- ## Three pipelines side-by-side
947
+ | Dimension | React 19 | Lark |
948
+ |-----------|----------|------|
949
+ | Component abstraction | Function components + Hooks | Class inheritance `View.extend` / `defineView` |
950
+ | State encapsulation | `useState` / `useReducer` | View instance fields, `create()` store, `State` |
951
+ | Side effects | `useEffect` / `useLayoutEffect` | `init` / `make` + `capture` / `release` |
952
+ | Render interruption | Fiber time-slicing, Suspense, Transition | Synchronous digest, not interruptible |
953
+ | Compile optimization | React Compiler (auto-memo) | Template compile-time only; no runtime auto-memo |
954
+ | Server rendering | RSC, streaming SSR | Not supported (design trade-off) |
955
+ | Cross-platform | React Native / DOM | Web DOM only |
956
+ | Event system | Synthetic Event | `document.body` capture-phase delegation + selector matching |
957
+ | Route guards | Third-party router libraries | Built-in `Router.beforeEach(asyncGuard)` + two-phase change |
949
958
 
950
- ```ts
951
- // Updater (view-local, manual)
952
- this.updater.set({ count: newCount }).digest();
959
+ The key difference is scheduling: React 19's Concurrent mode can interrupt and restart renders by lane priority. Lark's `Updater.digest()` is synchronous (though the internal `digestingQueue` supports re-entry) and never yields the main thread. For large lists or frequent updates, Lark has no time-slicing mechanism, which may cause long tasks; the advantage is predictable behavior and simpler debugging.
953
960
 
954
- // State (simple cross-view)
955
- State.set({ count: newCount }).digest();
956
- // to react:
957
- State.on("changed", (e) => {
958
- if (e.keys?.has("count")) this.assign();
959
- });
960
- // to clean up on view destroy:
961
- export default View.extend({ mixins: [State.clean("count")] });
961
+ ## Testing and Local Development
962
962
 
963
- // Store (complex cross-view, recommended for non-trivial state)
964
- const store = useCountStore(this);
965
- store.observe(this); // default: all keys (D5)
966
- useCountStore().increment(); // mutate from anywhere → all observers re-render
963
+ ```bash
964
+ pnpm install
965
+ pnpm test # vitest unit tests
966
+ pnpm test:coverage # coverage report
967
+ pnpm test:watch # watch mode
968
+ pnpm typecheck # tsc --noEmit
969
+ pnpm build # tsup produces ESM + CJS + dts
970
+ pnpm format # prettier formatting
967
971
  ```
968
972
 
969
- Key distinctions:
970
-
971
- | | State | Store |
972
- | -------------- | ------------------------------------------- | ----------------------------------------------------------- |
973
- | Write API | `State.set()` + `State.digest()` | direct: `store.key = value` |
974
- | Reactivity | Manual `digest()` notification | Automatic via Proxy |
975
- | Subscribe | `State.on("changed", ...)` + manual cleanup | `store.observe(view, ...)` with auto-cleanup |
976
- | Memory | Auto-deleted via `State.clean()` mixin | Persists until `store.$destroyFn()` |
977
- | Derived data | not supported | `computed(deps, fn)` |
978
- | Multi-instance | not supported | `multi(useStore)` |
979
- | Best for | Counters, toggles, page title, session info | Reactive handlers, derived state, multi-instance components |
980
-
981
- ## Updater APIs worth knowing
982
-
983
- - `updater.get(key?)` — read data; without key returns the whole data object.
984
- - `updater.set(data, excludes?)` — merge `data` into the view's data, track changed keys.
985
- - `updater.digest(data?, excludes?, callback?)` — render; optional `data` is set first. Supports re-digest during an active digest via an internal queue (the `null` sentinel marks digest boundaries).
986
- - `updater.snapshot()` — record the current monotonic `version`; pair with `altered()` to detect changes cheaply (no JSON.stringify).
987
- - `updater.altered()` — returns `boolean | undefined`. `undefined` if `snapshot` was never called.
988
- - `updater.translate(value)` — resolve a `SPLITTER + digits` ref token to its original value. Non-ref strings are returned as-is. The protocol is strict: only `SPLITTER` followed by ASCII digits qualifies.
989
- - `updater.parse(expr)` — **safe** path resolver. Accepts a dotted property path (`a.b.c`) or a numeric literal (`42`, `-1.5`). Anything else returns `undefined`. Does NOT eval arbitrary JS — CSP-safe.
990
- - `updater.getChangedKeys()` — `ReadonlySet<string>` of keys changed since the last digest.
991
-
992
- ## Frame APIs worth knowing
993
-
994
- - `Frame.get(id)` — look up a Frame by DOM id.
995
- - `Frame.getAll()` — registry as `Map<string, Frame>`.
996
- - `Frame.getRoot()` — current root or `undefined`.
997
- - `Frame.createRoot(id)` — create root (idempotent; ignores `id` after first creation).
998
- - `Frame.root(id)` — `@deprecated` alias to `createRoot`.
999
- - `frame.invoke(name, args?)` — call a method on the frame's view. If the view isn't yet rendered, the call is deferred until render.
1000
- - `frame.invokeTyped<V, K>(name, args)` — type-safe variant; carries the view's method signature through TS.
1001
- - `frame.children()` — array of child Frame ids (order is not stable).
1002
- - `frame.parent(level?)` — ancestor frame; defaults to parent (level=1).
1003
- - `frame.mountFrame(id, viewPath, params?)` — explicit child Frame creation.
1004
- - `frame.unmountFrame(id)` / `frame.mountZone(id?)` / `frame.unmountZone(id?)` — bulk operations.
1005
- - `Frame.on("add" | "remove", handler)` — lifecycle events.
1006
- - `frame.on("created" | "alter", handler)` — fires when all children have rendered / when child content changes.
1007
-
1008
- ## Framework APIs worth knowing
1009
-
1010
- - `Framework.boot(config)` — start the app.
1011
- - `Framework.getConfig()` / `Framework.getConfig(key)` — read config.
1012
- - `Framework.setConfig(patch)` — merge into config; returns the merged result.
1013
- - `Framework.config(...)` — `@deprecated`; still works.
1014
- - `Framework.isBooted()` — boolean.
1015
- - `Framework.use(names, callback?)` — async View loader. Returns `Promise<unknown[]>` when no callback is passed.
1016
- - `Framework.mark(host, key)` / `Framework.unmark(host)` — async callback validity tracking. Stored in a module-level `WeakMap`, does NOT pollute the host object with magic keys.
1017
- - `Framework.dispatch(target, type, init?)` — fire a custom DOM event.
1018
- - `Framework.task(fn, args?, ctx?)` — schedule a function for chunked execution (`scheduler.postTask` → `requestIdleCallback` → `setTimeout(0)`).
1019
- - `Framework.delay(ms)` — Promise-based setTimeout.
1020
- - `Framework.waitZoneViewsRendered(viewId, timeout?)` — Promise resolving to `Framework.WAIT_OK` (1) or `Framework.WAIT_TIMEOUT_OR_NOT_FOUND` (0).
1021
- - `Framework.applyStyle(idOrPairs, css?)` — inject CSS dynamically; returns a cleanup function.
1022
- - `Framework.guid(prefix?)` / `Framework.toMap(list, key?)` / `Framework.toUrl(...)` / `Framework.parseUrl(url)` / `Framework.mix(target, ...sources)` / `Framework.keys(obj)` / `Framework.inside(a, b)` / `Framework.node(idOrEl)` / `Framework.nodeId(el)` — utility helpers.
1023
- - `Framework.guard(o)` — Safeguard Proxy wrap (no-op outside debug mode).
1024
- - `Framework.Base` / `Framework.View` / `Framework.Frame` / `Framework.Cache` / `Framework.State` / `Framework.Router` — class re-exports.
1025
-
1026
- ## Vite vs Webpack at a glance
1027
-
1028
- | Feature | Vite (`larkMvcPlugin`) | Webpack (`larkMvcLoader`) |
1029
- | ------------------- | -------------------------------------------------------- | ------------------------------------------------------------ |
1030
- | Import path | `@lark.js/mvc/vite` | `@lark.js/mvc/webpack` |
1031
- | Type | Vite plugin (`resolveId` + `load` hooks, `enforce: pre`) | Standard Webpack loader |
1032
- | Configuration | `plugins: [larkMvcPlugin()]` | `module.rules` with the loader rule |
1033
- | Debug mode | `larkMvcPlugin({ debug: true })` | `use: [{ loader: larkMvcLoader, options: { debug: true } }]` |
1034
- | HTML entry handling | Vite handles `index.html` natively | MUST `exclude: /index\.html$/` so HtmlWebpackPlugin owns it |
1035
- | Dev server | Vite dev server (fast HMR) | webpack-dev-server |
1036
- | Template pipeline | Same: `extractGlobalVars` → `compileTemplate` | Same: `extractGlobalVars` → `compileTemplate` |
1037
-
1038
- Both produce compiled `.html` modules that import their runtime helpers from `@lark.js/mvc/runtime` (a 948-byte module containing `encHtml`, `strSafe`, `encUri`, `encQuote`, `refFn`).
1039
-
1040
- ## Common pitfalls
1041
-
1042
- 1. **`boot.ts` must live inside `src/`** — the entry HTML references `/src/boot.ts`, not `/boot.ts`.
1043
- 2. **`registerViewClass` before `Framework.boot()`** — all view classes (and their sub-components) must be registered before boot, OR you must provide a `FrameworkConfig.require` so unknown paths can be loaded on demand.
1044
- 3. **`.html` imports require the bundler integration** — they only work because the Vite plugin or Webpack loader compiles them at build time.
1045
- 4. **Use `State.set` + `State.digest`, not direct mutation** — direct mutation bypasses change detection. Debug mode (`window.__lark_Debug = true`) warns synchronously and dedupes the warning per key.
1046
- 5. **`observe` requires view binding for auto-cleanup** — `store.observe(this, ...)` tears down when the view destroys. Inner observes (no view) require explicit `keys` and explicit unsubscribe.
1047
- 6. **Event method names use `<>`, not `()`** — the pattern is `name<click>`, not `name(click)`.
1048
- 7. **`assign()` must call `snapshot()` and return `altered()`** — otherwise the framework can't tell if data actually changed.
1049
- 8. **Do not modify `view.signature`** — it's managed internally. Setting it to 0 destroys the view. The wrapped `render()` increments it.
1050
- 9. **`v-lark` containers are replaced** — content inside a `v-lark` element gets replaced by the child view's rendered output. Don't put authoring text there.
1051
- 10. **Webpack: exclude `index.html`** — `larkMvcLoader` must not process the entry HTML; HtmlWebpackPlugin owns it.
1052
- 11. **Webpack: import the loader as a value** — `loader: larkMvcLoader`, not `loader: "larkMvcLoader"`.
1053
- 12. **Store reads return cloned data** — `useStore(view).count` returns a deep clone (via `structuredClone`). Mutating it does NOT trigger reactivity. Only writes through `store.key = value` are reactive.
1054
- 13. **`forOf` requires `as`** — `{{forOf list item}}` is invalid; use `{{forOf list as item}}`.
1055
- 14. **Inner observe deduplication** — `store.observe(undefined, keys, callback)` is deduped on `key + observeKeys.join("-") + cb.toString()`. The same inner observe registers only once.
1056
- 15. **`wrapAsync` is signature-based** — the callback runs only if `view.signature` hasn't changed since `wrapAsync` was called.
1057
- 16. **Frame object pooling has a cap** — destroyed Frame objects are pooled up to `MAX_FRAME_POOL = 64`. Don't hold references to Frame instances after `unmountFrame()`.
1058
- 17. **Updater supports re-entrant digest** — calling `updater.digest()` inside an active digest is supported through `digestingQueue`. The `null` sentinel marks digest boundaries.
1059
- 18. **Store creator runs once** — at definition time. State persists across view mounts/unmounts. Call `useStore.$destroyFn()` (set via `Object.defineProperties` on the use-fn) to tear it down.
1060
- 19. **State for simple, Store for complex** — use `State.set` + `State.digest` for lightweight shared values. Reach for `defineStore` when you need reactive handlers, derived data via `computed(deps, fn)`, multi-instance isolation via `multi()`, or store-internal reactions via inner `observe`. Always pair State writes with `State.clean(keys)` mixin on consumers so data doesn't leak globally.
1061
- 20. **MF view paths use the remote project prefix** — `v-lark="remote-app/views/home"` triggers async loading through `FrameworkConfig.require` if the path isn't yet registered. Ensure `require` is configured AND `ModuleFederationPlugin` shares `@lark.js/mvc` as a singleton.
1062
- 21. **`CrossSite` is the export name** — register it as `registerViewClass("cross-site", CrossSite)`.
1063
- 22. **CrossSite uses `view=` not `xview=`** — `v-lark="cross-site?view=remote-app/views/home"`.
1064
- 23. **`Framework.use()` returns a Promise** — without the optional callback, it resolves to `unknown[]`. Without a configured `require`, it falls back to dynamic `import()`.
1065
- 24. **`Updater.parse` is path-only, no eval** — it accepts dotted paths and numeric literals. `updater.parse("1 + 2")` returns `undefined`. CSP-safe by design.
1066
- 25. **`LarkInnerKeys` for VDOM short-circuits** — `ldk` skips the entire diff for static elements; `lak` skips attribute diff but still diffs children; `lvk` is an assign-optimization marker.
1067
- 26. **MF: `splitChunks.chunks` MUST be `"async"`** — using `"all"` extracts `@lark.js/mvc` into a separate vendor chunk, breaking shared-scope initialization. The error surfaces as `ScriptExternalLoadError: Loading script failed (missing)`.
1068
- 27. **MF: `new Frame(containerId)` for independent contexts** — `Frame.createRoot()` (and the deprecated `Frame.root()`) is a singleton that ignores later id arguments. Each MF mount needs its own `new Frame()`.
1069
- 28. **MF: remote must explicitly import CSS** — Webpack bundles only CSS reachable from the exposed module's import graph. Without an `import "../index.css"` in the exposed entry, host pages won't receive utility classes used in the templates.
1070
- 29. **Sub-component `v-lark` paths must match exactly** — template strings embed the paths at build time; renaming a `registerViewClass` path without updating the template breaks the load.
1071
- 30. **Dynamic `import()` shape is unknown** — for chunk splitting, use a small `extractDefault()` helper to unwrap the ESM default, then cast with `as typeof View` (NOT `as any`).
1072
-
1073
- ## Migration notes (recent API changes)
1074
-
1075
- - `ChangeEvent.keys` is now `ReadonlySet<string>` (was `Record<string, 1>`). Use `keys.has("foo")` instead of `keys.foo`. Affects `State.on("changed")` handlers and `view.observeState` callbacks.
1076
- - `StateInterface.diff()` returns `ReadonlySet<string>`.
1077
- - `Framework.toUrl(path, params, keepEmpty?)` — `keepEmpty` is now `Set<string>` (was `Record<string, number>`).
1078
- - `Updater.set/digest`, `State.set/digest`, and `setData` take `excludes?: ReadonlySet<string>` (was `Set<string>`).
1079
- - `Frame.root(id)` is `@deprecated`. Use `Frame.getRoot()` for reads, `Frame.createRoot(id)` for the explicit singleton creation, or `new Frame(id)` for independent mounts.
1080
- - `Framework.config(...)` is `@deprecated`. Use `Framework.getConfig(key?)` and `Framework.setConfig(patch)`.
1081
- - `Updater.parse` no longer evals — only safe path/literal resolution. Migrate to a small helper function if you needed expression eval.
1082
- - `mark.ts` no longer writes magic keys onto host objects — it uses a module-level `WeakMap`. Works on frozen objects.
1083
- - `Cache.del` now splices immediately (was leaving tombstones until the next eviction).
1084
-
1085
- ## References
973
+ `vitest.config.ts` targets the `tests/` directory with 16 test files covering core modules. `tsup.config.ts` defines four entry points (`index` / `vite` / `webpack` / `runtime`) with output in `dist/`.
1086
974
 
1087
- For deeper detail than this guide:
975
+ ## License
1088
976
 
1089
- - `references/api-reference.md` Complete API signatures for every Lark module.
1090
- - `references/template-syntax.md` — Full template language reference, including compilation pipeline, operators, control flow, encoders, and debug mode.
977
+ ISC. See `LICENSE` in the repository root.