@hypen-space/core 0.2.0 → 0.2.2

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 ADDED
@@ -0,0 +1,373 @@
1
+ # @hypen-space/core
2
+
3
+ The reactive runtime for Hypen - a declarative UI language that separates what your UI looks like from how it behaves.
4
+
5
+ ## What is Hypen?
6
+
7
+ Hypen lets you write UI templates in a clean, declarative syntax:
8
+
9
+ ```hypen
10
+ Column {
11
+ Text("Hello, ${state.user.name}!")
12
+ Button(onClick: @actions.logout) {
13
+ Text("Sign Out")
14
+ }
15
+ }
16
+ ```
17
+
18
+ The `@hypen-space/core` package provides the engine that:
19
+
20
+ 1. **Parses** your Hypen templates
21
+ 2. **Tracks** reactive state bindings (like `${state.user.name}`)
22
+ 3. **Generates patches** when state changes (instead of re-rendering everything)
23
+ 4. **Dispatches actions** triggered from the UI (like `@actions.logout`)
24
+
25
+ You provide a renderer that applies those patches to your platform (DOM, Canvas, Native, etc).
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ npm install @hypen-space/core
31
+ # or
32
+ bun add @hypen-space/core
33
+ ```
34
+
35
+ ## Core Concepts
36
+
37
+ ### Templates
38
+
39
+ Hypen templates describe your UI structure. Components can have:
40
+
41
+ - **Arguments**: `Text("Hello")` or `Button(disabled: true)`
42
+ - **Children**: Nested inside `{ }` braces
43
+ - **Applicators**: Chained styling like `.padding(16).color(blue)`
44
+
45
+ ### State Bindings
46
+
47
+ Use `${state.path}` to bind template values to your module's state:
48
+
49
+ ```hypen
50
+ Text("Count: ${state.count}")
51
+ Text("User: ${state.user.name}")
52
+ ```
53
+
54
+ When state changes, only the affected parts of the UI update.
55
+
56
+ ### Actions
57
+
58
+ Use `@actions.name` to dispatch events from the UI to your module:
59
+
60
+ ```hypen
61
+ Button(onClick: @actions.increment) { Text("+") }
62
+ Button(onClick: @actions.submitForm) { Text("Submit") }
63
+ ```
64
+
65
+ ### Patches
66
+
67
+ The engine doesn't manipulate the UI directly. Instead, it emits **patches** - minimal instructions describing what changed:
68
+
69
+ ```typescript
70
+ { type: "Create", id: "1", elementType: "Text", props: { text: "Hello" } }
71
+ { type: "SetProp", id: "1", name: "text", value: "Hello, World" }
72
+ { type: "Remove", id: "1" }
73
+ ```
74
+
75
+ Your renderer applies these patches to the actual platform.
76
+
77
+ ## Quick Start
78
+
79
+ ```typescript
80
+ import { Engine, app } from "@hypen-space/core";
81
+
82
+ // 1. Define your module's state and actions
83
+ const counter = app
84
+ .defineState({ count: 0 })
85
+ .onAction("increment", ({ state }) => state.count++)
86
+ .onAction("decrement", ({ state }) => state.count--)
87
+ .build();
88
+
89
+ // 2. Initialize the engine
90
+ const engine = new Engine();
91
+ await engine.init();
92
+
93
+ // 3. Connect your renderer
94
+ engine.setRenderCallback((patches) => {
95
+ myRenderer.applyPatches(patches);
96
+ });
97
+
98
+ // 4. Register the module and render
99
+ engine.setModule("counter", counter.actions, counter.stateKeys, counter.initialState);
100
+
101
+ engine.renderSource(`
102
+ Column {
103
+ Text("Count: \${state.count}")
104
+ Row {
105
+ Button(onClick: @actions.decrement) { Text("-") }
106
+ Button(onClick: @actions.increment) { Text("+") }
107
+ }
108
+ }
109
+ `);
110
+ ```
111
+
112
+ ## Modules
113
+
114
+ Modules manage state and handle actions. Use the `app` builder to define them:
115
+
116
+ ```typescript
117
+ import { app } from "@hypen-space/core";
118
+
119
+ interface UserState {
120
+ user: { id: string; name: string } | null;
121
+ loading: boolean;
122
+ }
123
+
124
+ const userModule = app
125
+ .defineState<UserState>({ user: null, loading: false })
126
+
127
+ // Called when module is created
128
+ .onCreated(async ({ state, next }) => {
129
+ state.loading = true;
130
+ next(); // Sync state changes to engine
131
+ })
132
+
133
+ // Handle actions dispatched from UI
134
+ .onAction("loadUser", async ({ state, action, next }) => {
135
+ const userId = action.payload?.id;
136
+ state.user = await fetchUser(userId);
137
+ state.loading = false;
138
+ next();
139
+ })
140
+
141
+ .onAction("logout", ({ state, next }) => {
142
+ state.user = null;
143
+ next();
144
+ })
145
+
146
+ // Called when module is destroyed
147
+ .onDestroyed(({ state }) => {
148
+ console.log("Cleanup");
149
+ })
150
+
151
+ .build();
152
+ ```
153
+
154
+ The `next()` callback synchronizes state changes with the engine after async operations.
155
+
156
+ ## State
157
+
158
+ State is automatically tracked via Proxy. Mutations trigger UI updates:
159
+
160
+ ```typescript
161
+ import { createObservableState, batchStateUpdates, getStateSnapshot } from "@hypen-space/core";
162
+
163
+ const state = createObservableState({ count: 0, items: [] }, {
164
+ onChange: (path, oldVal, newVal) => {
165
+ console.log(`${path.join(".")} changed: ${oldVal} -> ${newVal}`);
166
+ }
167
+ });
168
+
169
+ // Direct mutations are tracked
170
+ state.count = 5;
171
+ state.items.push("item");
172
+
173
+ // Batch multiple updates into one render cycle
174
+ batchStateUpdates(state, () => {
175
+ state.count = 10;
176
+ state.items = ["a", "b", "c"];
177
+ });
178
+
179
+ // Get an immutable snapshot
180
+ const snapshot = getStateSnapshot(state);
181
+ ```
182
+
183
+ ## Custom Renderers
184
+
185
+ Extend `BaseRenderer` to render to any platform:
186
+
187
+ ```typescript
188
+ import { BaseRenderer } from "@hypen-space/core";
189
+
190
+ class MyRenderer extends BaseRenderer {
191
+ protected onCreate(id: string, type: string, props: Record<string, any>) {
192
+ // Create an element
193
+ }
194
+ protected onSetProp(id: string, name: string, value: any) {
195
+ // Update a property
196
+ }
197
+ protected onSetText(id: string, text: string) {
198
+ // Set text content
199
+ }
200
+ protected onInsert(parentId: string, id: string, beforeId?: string) {
201
+ // Insert into parent
202
+ }
203
+ protected onMove(parentId: string, id: string, beforeId?: string) {
204
+ // Reorder element
205
+ }
206
+ protected onRemove(id: string) {
207
+ // Remove element
208
+ }
209
+ }
210
+ ```
211
+
212
+ See `@hypen-space/web` for a DOM renderer implementation.
213
+
214
+ ## Routing
215
+
216
+ Built-in hash or pathname routing:
217
+
218
+ ```typescript
219
+ import { HypenRouter } from "@hypen-space/core";
220
+
221
+ const router = new HypenRouter();
222
+
223
+ router.navigate("/products/123");
224
+
225
+ router.subscribe((state) => {
226
+ console.log("Path:", state.currentPath);
227
+ console.log("Params:", state.params); // { id: "123" }
228
+ console.log("Query:", state.query);
229
+ });
230
+ ```
231
+
232
+ Use built-in components in templates:
233
+
234
+ ```hypen
235
+ Router {
236
+ Route(path: "/") { HomePage }
237
+ Route(path: "/products/:id") { ProductPage }
238
+ }
239
+ Link(to: "/products/42") { Text("View Product") }
240
+ ```
241
+
242
+ ## Component Discovery
243
+
244
+ For larger apps, organize components as files and auto-discover them:
245
+
246
+ ```typescript
247
+ import { discoverComponents, loadDiscoveredComponents } from "@hypen-space/core";
248
+
249
+ const components = await discoverComponents("./src/components");
250
+ const loaded = await loadDiscoveredComponents(components);
251
+ ```
252
+
253
+ Supported file patterns:
254
+
255
+ ```
256
+ Counter/
257
+ ├── component.hypen # Template
258
+ └── component.ts # Module
259
+
260
+ Counter.hypen # Or sibling files
261
+ Counter.ts
262
+
263
+ Counter/
264
+ ├── index.hypen # Or index-based
265
+ └── index.ts
266
+ ```
267
+
268
+ Watch for changes (hot reload):
269
+
270
+ ```typescript
271
+ import { watchComponents } from "@hypen-space/core";
272
+
273
+ const watcher = watchComponents("./src/components", {
274
+ onUpdate: (c) => console.log("Updated:", c.name),
275
+ });
276
+ ```
277
+
278
+ ## Component Loader
279
+
280
+ Register components programmatically:
281
+
282
+ ```typescript
283
+ import { componentLoader, ComponentLoader } from "@hypen-space/core";
284
+
285
+ // Global loader
286
+ componentLoader.register("Counter", counterModule, counterTemplate);
287
+ componentLoader.get("Counter");
288
+ componentLoader.has("Counter");
289
+
290
+ // Or create your own
291
+ const loader = new ComponentLoader();
292
+ await loader.loadFromComponentsDir("./src/components");
293
+ ```
294
+
295
+ ## Remote UI
296
+
297
+ Connect to a Hypen server for server-driven UI:
298
+
299
+ ```typescript
300
+ import { RemoteEngine } from "@hypen-space/core";
301
+
302
+ const remote = new RemoteEngine("ws://localhost:3000", {
303
+ autoReconnect: true,
304
+ });
305
+
306
+ remote
307
+ .onPatches((patches) => renderer.applyPatches(patches))
308
+ .onStateUpdate((state) => console.log("Server state:", state))
309
+ .onConnect(() => console.log("Connected"));
310
+
311
+ await remote.connect();
312
+ remote.dispatchAction("loadData", { page: 1 });
313
+ ```
314
+
315
+ ## Global Context
316
+
317
+ Share state and events across modules:
318
+
319
+ ```typescript
320
+ import { HypenGlobalContext } from "@hypen-space/core";
321
+
322
+ const context = new HypenGlobalContext();
323
+
324
+ context.registerModule("auth", authModule);
325
+ context.registerModule("cart", cartModule);
326
+
327
+ // Cross-module access
328
+ const user = context.getModule("auth").getState().user;
329
+
330
+ // Event bus
331
+ context.on("userLoggedIn", (user) => { /* ... */ });
332
+ context.emit("userLoggedIn", { id: "1", name: "Ian" });
333
+ ```
334
+
335
+ ## Browser vs Node.js
336
+
337
+ ```typescript
338
+ // Node.js / Bundler - WASM loads automatically
339
+ import { Engine } from "@hypen-space/core";
340
+ const engine = new Engine();
341
+ await engine.init();
342
+
343
+ // Browser - specify WASM path
344
+ import { BrowserEngine } from "@hypen-space/core";
345
+ const engine = new BrowserEngine();
346
+ await engine.init({ wasmPath: "/hypen_engine_bg.wasm" });
347
+ ```
348
+
349
+ ## Package Exports
350
+
351
+ | Export | Description |
352
+ |--------|-------------|
353
+ | `@hypen-space/core` | Main entry point |
354
+ | `@hypen-space/core/engine` | Low-level WASM engine |
355
+ | `@hypen-space/core/engine/browser` | Browser-optimized engine |
356
+ | `@hypen-space/core/app` | Module builder |
357
+ | `@hypen-space/core/state` | Observable state utilities |
358
+ | `@hypen-space/core/renderer` | Abstract renderer |
359
+ | `@hypen-space/core/router` | Routing system |
360
+ | `@hypen-space/core/context` | Global context |
361
+ | `@hypen-space/core/remote` | Remote UI protocol |
362
+ | `@hypen-space/core/loader` | Component loader |
363
+ | `@hypen-space/core/discovery` | Component discovery |
364
+ | `@hypen-space/core/components` | Built-in Router, Route, Link |
365
+
366
+ ## Requirements
367
+
368
+ - Node.js >= 18.0.0
369
+ - TypeScript 5+ (optional)
370
+
371
+ ## License
372
+
373
+ MIT
@@ -0,0 +1,89 @@
1
+ import {
2
+ app
3
+ } from "../app.js";
4
+ import"../state.js";
5
+ import"../../chunk-5va59f7m.js";
6
+
7
+ // src/components/builtin.ts
8
+ var Router = app.defineState({
9
+ currentPath: "/",
10
+ matchedRoute: null,
11
+ routeParams: {}
12
+ }, { name: "__Router" }).onCreated((state, context) => {
13
+ if (!context) {
14
+ console.error("[Router] Requires global context");
15
+ return;
16
+ }
17
+ const router = context.__router;
18
+ if (!router) {
19
+ console.error("[Router] Router not found in context");
20
+ return;
21
+ }
22
+ const hypenEngine = context.__hypenEngine;
23
+ const updateRouteVisibility = async (currentPath) => {
24
+ const routeElements = document.querySelectorAll('[data-hypen-type="route"]');
25
+ let matchFound = false;
26
+ for (let index = 0;index < routeElements.length; index++) {
27
+ const routeEl = routeElements[index];
28
+ const htmlEl = routeEl;
29
+ const routePath = htmlEl.dataset.routePath || "/";
30
+ const isMatch = routePath === currentPath;
31
+ htmlEl.style.display = isMatch ? "flex" : "none";
32
+ if (isMatch) {
33
+ matchFound = true;
34
+ const componentName = htmlEl.dataset.routeComponent;
35
+ const isLazy = htmlEl.dataset.routeLazy === "true";
36
+ const hasContent = htmlEl.children.length > 0;
37
+ if (componentName && !hasContent && hypenEngine) {
38
+ try {
39
+ await hypenEngine.renderLazyRoute(routePath, componentName, htmlEl);
40
+ } catch (err) {
41
+ console.error(`[Router] Failed to render route ${routePath}:`, err);
42
+ }
43
+ }
44
+ }
45
+ }
46
+ if (!matchFound) {
47
+ console.warn(`[Router] No route matched path: ${currentPath}. Available routes:`, Array.from(routeElements).map((el) => el.dataset.routePath));
48
+ }
49
+ };
50
+ setTimeout(() => {
51
+ updateRouteVisibility(state.currentPath);
52
+ }, 100);
53
+ router.onNavigate((routeState) => {
54
+ state.currentPath = routeState.currentPath;
55
+ state.routeParams = routeState.params;
56
+ updateRouteVisibility(routeState.currentPath);
57
+ });
58
+ }).build();
59
+ var Route = app.defineState({}, { name: "__Route" }).build();
60
+ var Link = app.defineState({
61
+ to: "/",
62
+ isActive: false
63
+ }, { name: "__Link" }).onAction("navigate", ({ state, context }) => {
64
+ const router = context?.__router;
65
+ if (!router) {
66
+ console.error("[Link] Requires router context");
67
+ return;
68
+ }
69
+ const targetPath = state.to;
70
+ router.push(targetPath);
71
+ }).onCreated((state, context) => {
72
+ if (!context)
73
+ return;
74
+ const router = context.__router;
75
+ if (router) {
76
+ router.onNavigate((routeState) => {
77
+ state.isActive = routeState.currentPath === state.to;
78
+ });
79
+ }
80
+ }).build();
81
+ export {
82
+ Router,
83
+ Route,
84
+ Link
85
+ };
86
+
87
+ export { Router, Route, Link };
88
+
89
+ //# debugId=DCABC4D99E5064EE64756E2164756E21
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/components/builtin.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * Built-in Hypen Components\n * Framework-provided components like Router and Route\n */\n\nimport { app } from \"../app.js\";\nimport type { HypenRouter } from \"../router.js\";\n\n/**\n * Router Component\n * Automatically matches routes and renders the appropriate child Route\n *\n * Usage in Hypen DSL:\n * Router {\n * Route(path: \"/\") { HomePage }\n * Route(path: \"/products\") { ProductList }\n * Route(path: \"/users/:id\") { UserProfile }\n * }\n */\nexport const Router = app\n .defineState(\n {\n currentPath: \"/\",\n matchedRoute: null as any,\n routeParams: {} as Record<string, string>,\n },\n { name: \"__Router\" }\n )\n .onCreated((state, context) => {\n if (!context) {\n console.error(\"[Router] Requires global context\");\n return;\n }\n\n // Subscribe to router changes\n const router = (context as any).__router as HypenRouter;\n if (!router) {\n console.error(\"[Router] Router not found in context\");\n return;\n }\n\n // Get access to the Hypen engine for lazy rendering\n const hypenEngine = (context as any).__hypenEngine;\n\n // Function to render only the matching route\n const updateRouteVisibility = async (currentPath: string) => {\n // Find all Route elements in the DOM\n const routeElements = document.querySelectorAll(\n '[data-hypen-type=\"route\"]'\n );\n\n let matchFound = false;\n\n for (let index = 0; index < routeElements.length; index++) {\n const routeEl = routeElements[index];\n const htmlEl = routeEl as HTMLElement;\n const routePath = htmlEl.dataset.routePath || \"/\";\n\n // Simple path matching (exact match for now)\n const isMatch = routePath === currentPath;\n\n // Only show the matching route, hide all others\n htmlEl.style.display = isMatch ? \"flex\" : \"none\";\n\n if (isMatch) {\n matchFound = true;\n\n // Check if this route needs to be rendered\n const componentName = htmlEl.dataset.routeComponent;\n const isLazy = htmlEl.dataset.routeLazy === \"true\";\n const hasContent = htmlEl.children.length > 0;\n\n // Render if route has no content and has a component name\n if (componentName && !hasContent && hypenEngine) {\n try {\n await hypenEngine.renderLazyRoute(\n routePath,\n componentName,\n htmlEl\n );\n } catch (err) {\n console.error(`[Router] Failed to render route ${routePath}:`, err);\n }\n }\n }\n }\n\n if (!matchFound) {\n console.warn(\n `[Router] No route matched path: ${currentPath}. Available routes:`,\n Array.from(routeElements).map(\n (el: Element) => (el as HTMLElement).dataset.routePath\n )\n );\n }\n };\n\n // Initial route visibility (after DOM is ready)\n setTimeout(() => {\n updateRouteVisibility(state.currentPath);\n }, 100);\n\n router.onNavigate((routeState) => {\n state.currentPath = routeState.currentPath;\n state.routeParams = routeState.params;\n\n // Update route visibility when path changes\n updateRouteVisibility(routeState.currentPath);\n });\n })\n .build();\n\n/**\n * Route Component\n * Defines a route pattern and its content\n * This is just a marker component - Router processes it\n *\n * Props:\n * - path: string - Route pattern (e.g., \"/\", \"/users/:id\", \"/dashboard/*\")\n *\n * Usage:\n * Route(path: \"/products\") {\n * ProductList\n * }\n */\nexport const Route = app.defineState({}, { name: \"__Route\" }).build();\n\n/**\n * Navigation Link Component\n * Navigates to a route when clicked\n *\n * Props:\n * - to: string - Target path\n *\n * Usage:\n * Link(to: \"/products\") {\n * Text(\"View Products\")\n * }\n */\nexport const Link = app\n .defineState(\n {\n to: \"/\",\n isActive: false,\n },\n { name: \"__Link\" }\n )\n .onAction(\"navigate\", ({ state, context }) => {\n const router = (context as any)?.__router as HypenRouter;\n if (!router) {\n console.error(\"[Link] Requires router context\");\n return;\n }\n\n const targetPath = state.to;\n router.push(targetPath);\n })\n .onCreated((state, context) => {\n if (!context) return;\n\n // Check if current path matches this link's target\n const router = (context as any).__router as HypenRouter;\n if (router) {\n router.onNavigate((routeState) => {\n state.isActive = routeState.currentPath === state.to;\n });\n }\n })\n .build();\n"
6
+ ],
7
+ "mappings": ";;;;;;;AAmBO,IAAM,SAAS,IACnB,YACC;AAAA,EACE,aAAa;AAAA,EACb,cAAc;AAAA,EACd,aAAa,CAAC;AAChB,GACA,EAAE,MAAM,WAAW,CACrB,EACC,UAAU,CAAC,OAAO,YAAY;AAAA,EAC7B,IAAI,CAAC,SAAS;AAAA,IACZ,QAAQ,MAAM,kCAAkC;AAAA,IAChD;AAAA,EACF;AAAA,EAGA,MAAM,SAAU,QAAgB;AAAA,EAChC,IAAI,CAAC,QAAQ;AAAA,IACX,QAAQ,MAAM,sCAAsC;AAAA,IACpD;AAAA,EACF;AAAA,EAGA,MAAM,cAAe,QAAgB;AAAA,EAGrC,MAAM,wBAAwB,OAAO,gBAAwB;AAAA,IAE3D,MAAM,gBAAgB,SAAS,iBAC7B,2BACF;AAAA,IAEA,IAAI,aAAa;AAAA,IAEjB,SAAS,QAAQ,EAAG,QAAQ,cAAc,QAAQ,SAAS;AAAA,MACzD,MAAM,UAAU,cAAc;AAAA,MAC9B,MAAM,SAAS;AAAA,MACf,MAAM,YAAY,OAAO,QAAQ,aAAa;AAAA,MAG9C,MAAM,UAAU,cAAc;AAAA,MAG9B,OAAO,MAAM,UAAU,UAAU,SAAS;AAAA,MAE1C,IAAI,SAAS;AAAA,QACX,aAAa;AAAA,QAGb,MAAM,gBAAgB,OAAO,QAAQ;AAAA,QACrC,MAAM,SAAS,OAAO,QAAQ,cAAc;AAAA,QAC5C,MAAM,aAAa,OAAO,SAAS,SAAS;AAAA,QAG5C,IAAI,iBAAiB,CAAC,cAAc,aAAa;AAAA,UAC/C,IAAI;AAAA,YACF,MAAM,YAAY,gBAChB,WACA,eACA,MACF;AAAA,YACA,OAAO,KAAK;AAAA,YACZ,QAAQ,MAAM,mCAAmC,cAAc,GAAG;AAAA;AAAA,QAEtE;AAAA,MACF;AAAA,IACF;AAAA,IAEA,IAAI,CAAC,YAAY;AAAA,MACf,QAAQ,KACN,mCAAmC,kCACnC,MAAM,KAAK,aAAa,EAAE,IACxB,CAAC,OAAiB,GAAmB,QAAQ,SAC/C,CACF;AAAA,IACF;AAAA;AAAA,EAIF,WAAW,MAAM;AAAA,IACf,sBAAsB,MAAM,WAAW;AAAA,KACtC,GAAG;AAAA,EAEN,OAAO,WAAW,CAAC,eAAe;AAAA,IAChC,MAAM,cAAc,WAAW;AAAA,IAC/B,MAAM,cAAc,WAAW;AAAA,IAG/B,sBAAsB,WAAW,WAAW;AAAA,GAC7C;AAAA,CACF,EACA,MAAM;AAeF,IAAM,QAAQ,IAAI,YAAY,CAAC,GAAG,EAAE,MAAM,UAAU,CAAC,EAAE,MAAM;AAc7D,IAAM,OAAO,IACjB,YACC;AAAA,EACE,IAAI;AAAA,EACJ,UAAU;AACZ,GACA,EAAE,MAAM,SAAS,CACnB,EACC,SAAS,YAAY,GAAG,OAAO,cAAc;AAAA,EAC5C,MAAM,SAAU,SAAiB;AAAA,EACjC,IAAI,CAAC,QAAQ;AAAA,IACX,QAAQ,MAAM,gCAAgC;AAAA,IAC9C;AAAA,EACF;AAAA,EAEA,MAAM,aAAa,MAAM;AAAA,EACzB,OAAO,KAAK,UAAU;AAAA,CACvB,EACA,UAAU,CAAC,OAAO,YAAY;AAAA,EAC7B,IAAI,CAAC;AAAA,IAAS;AAAA,EAGd,MAAM,SAAU,QAAgB;AAAA,EAChC,IAAI,QAAQ;AAAA,IACV,OAAO,WAAW,CAAC,eAAe;AAAA,MAChC,MAAM,WAAW,WAAW,gBAAgB,MAAM;AAAA,KACnD;AAAA,EACH;AAAA,CACD,EACA,MAAM;",
8
+ "debugId": "DCABC4D99E5064EE64756E2164756E21",
9
+ "names": []
10
+ }