unicycle 1.0.0

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Uplift Systems Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,463 @@
1
+ # Unicycle
2
+
3
+ Unicycle is a lightweight, routed model-view-intent (MVI) plus sinks framework built on top of [Cycle.js](https://cycle.js.org), [Vite](https://vite.dev), and [xstream](https://github.com/staltz/xstream).
4
+
5
+ It assumes that you use the aforementioned tooling. This framework is very opinionated in that way.
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [Getting Started](#getting-started)
10
+ 1. [Installation](#installation)
11
+ 2. [Project Structure](#project-structure)
12
+ 2. [Model-View-Intent Basics](#model-view-intent-basics)
13
+ 1. [App-Level MVI](#app-level-mvi)
14
+ 2. [Route-Level MVI](#route-level-mvi)
15
+ 3. [Wrapping it Up](#wrapping-it-up)
16
+
17
+ ## Getting Started
18
+
19
+ ### Installation
20
+
21
+ First, install the Unicycle package.
22
+
23
+ ```bash
24
+ npm install unicycle
25
+ ```
26
+
27
+ Next, install core dependencies.
28
+
29
+ ```bash
30
+ npm install @cycle/dom xstream
31
+ npm install -D vite
32
+ ```
33
+
34
+ When you set up your package, it assumes it is of type `module`.
35
+
36
+ This is the bare minimum of what your `package.json` should look like:
37
+
38
+ ```JSON
39
+ {
40
+ "name": "your-package",
41
+ "version": "x.x.x",
42
+ "scripts": {
43
+ "dev": "vite",
44
+ "build": "vite build"
45
+ },
46
+ "type": "module",
47
+ "dependencies": {
48
+ "@cycle/dom": "x.x.x",
49
+ "unicycle": "x.x.x",
50
+ "xstream": "x.x.x"
51
+ },
52
+ "devDependencies": {
53
+ "vite": "x.x.x"
54
+ }
55
+ }
56
+ ```
57
+
58
+ ### Project Structure
59
+
60
+ A Unicycle project takes the following structure:
61
+
62
+ ```
63
+ project-root/
64
+ ├─src/
65
+ | ├─app/
66
+ | | ├─app.client.js
67
+ | | ├─app.intent.js
68
+ | | ├─app.model.js
69
+ | | └─app.view.js
70
+ | ├─components/
71
+ | ├─routes/
72
+ | | └─route_name
73
+ | | | ├─route_name.intent.js
74
+ | | | ├─route_name.model.js
75
+ | | | ├─route_name.route.js
76
+ | | | └─route_name.view.js
77
+ | ├─static/
78
+ | └─utils/
79
+ ├─index.html
80
+ ├─package.json
81
+ └─vite.config.js
82
+ ```
83
+
84
+ The `/src/app` folder contains your application-level code, which includes model, view, intent, and other sinks, which runs for every route.
85
+
86
+ The `/src/routes` folder contains each route as a sub-folder. Those contain your route-specific models, views, intents, and sinks.
87
+
88
+ Other optional folders for organization include a `/src/components` folder to store your view components. The `/src/static` folder can contain your static assets. The `/src/utils` can organize other utilities and helpers.
89
+
90
+ ## Model-View-Intent Basics
91
+
92
+ For example, one can create a simple "cookie clicker" application where clicking a button increments a counter and tells how many times the button has been clicked.
93
+
94
+ Although one can design this app non-routed, this example will show it routed to cover the entire Unicycle framework.
95
+
96
+ ### App-Level MVI
97
+
98
+ Starting at the app level, create `/src/app/app.client.js`. This provides application-level configuration.
99
+
100
+ #### App Configuration
101
+
102
+ ```JavaScript
103
+ // app.client.js
104
+
105
+ export default () => ({
106
+ drivers: {
107
+ // ... Additional drivers
108
+ },
109
+ defaults: {
110
+ // ... Default streams for each driver
111
+ },
112
+ state: {
113
+ // ... Initial app state
114
+ },
115
+ });
116
+ ```
117
+
118
+ The app-level configuration should export a function that returns an object which can contain drivers, defaults, and state.
119
+
120
+ Drivers are key-value pairs of the sink name to the `makeDriver` functions provided by any driver packages you install (e.g. [@cycle/http](https://cycle.js.org/api/http.html)).
121
+
122
+ Defaults is an object of key-value pairs of the sink name and the default stream you wish to be used if the route does not provide a sink.
123
+
124
+ For instance, if every route does not provide an `http` sink, you can define the default as `http: xs.empty()`.
125
+
126
+ This is necessary for every driver you use.
127
+
128
+ Finally, state defines the initial app-level (or global) state.
129
+
130
+ Since this application is simple, we can even omit creating this file entirely.
131
+
132
+ #### App View
133
+
134
+ The next file is the view. This file is named `/src/app/app.view.js`.
135
+
136
+ ```JavaScript
137
+ // app.view.js
138
+
139
+ import { h1, main, p } from "@cycle/dom";
140
+
141
+ export default (dom, global) => main([
142
+ h1(["Cookie Clicker"]),
143
+ dom ?? p(["Page not found"]),
144
+ ]);
145
+ ```
146
+
147
+ This view is rather simplistic. The app-level view should export a function that returns virtual DOM.
148
+
149
+ It takes in two parameters, `dom` and `global`.
150
+
151
+ The `dom` parameter is the route-level virtual DOM which can be "wrapped" by your app skeleton.
152
+
153
+ The `global` parameter is the app-level state.
154
+
155
+ Notice how we conditionally check if the `dom` is undefined, we supply a default which could be a page-not-found message.
156
+
157
+ This pretty much sums it up for the app-level files to create. But you can create other files like intent, model, and sinks for any of the drivers you've installed.
158
+
159
+ ### Route-Level MVI
160
+
161
+ Next, routes can be created, which are specific portions of your app that are accessed via its unique URL. Hence, why it's routed.
162
+
163
+ For our example cookie clicker project, we just need one route.
164
+
165
+ #### Example Route Definition
166
+
167
+ Create a folder under `/src/routes` to make a route. By nature, each route name (or id) must be unique.
168
+
169
+ For example, one can name this route `index`. So create a folder called `/src/routes/index`.
170
+
171
+ The required file for a route is the route definition. This file will be called `index.route.js`.
172
+
173
+ By convention, file names follow this pattern: `[route_name].[file_type].js`.
174
+
175
+ ```JavaScript
176
+ // index.route.js
177
+
178
+ export default () => ({
179
+ path: "/",
180
+ state: {
181
+ count: 0,
182
+ },
183
+ });
184
+ ```
185
+
186
+ This configuration allows you to specify two things. First, the path for the route can be specified either as a string, or an array of strings if the route has multiple paths.
187
+
188
+ You can also specify route parameters by prefixing any part of the slug with `:parameter_name`. So, `parameter_name` will be automatically made available under `global.parameters` in the model, for instance.
189
+
190
+ Second, the initial state can be defined for the route.
191
+
192
+ For our cookie clicker, we're interested in how many times the button is clicked, so we'll store a `count` value.
193
+
194
+ #### Intent Semantics
195
+
196
+ Next, one can write the intent. We'll name this file `/src/routes/index/index.intent.js`.
197
+
198
+ ```JavaScript
199
+ // index.intent.js
200
+
201
+ export default (on, global$) => ({
202
+ increment: on.click({
203
+ intent: "increment-count",
204
+ }),
205
+ });
206
+ ```
207
+
208
+ An intent file should export a function that returns an object representing an intent definition.
209
+
210
+ Intents can be described as a key-value pair of intent names to the intent listeners.
211
+
212
+ We provide two parameters to this function, `on` and `global$`.
213
+
214
+ The `on` parameter is an object already instantiated with the `sources` that come in from the drivers. This is a helper with easy, short-hand ways of writing intents.
215
+
216
+ For our application, we're interested to listen to when the button is clicked. So we simply define it as `on.click({})`.
217
+
218
+ We want to target a button with a data attribute of `data-intent="increment-count"`.
219
+
220
+ Other event handlers are available as follows:
221
+
222
+ ```JavaScript
223
+ on.click() // Targets click events.
224
+ on.change() // Targets change events.
225
+ on.input() // Targets input events.
226
+ on.submit() // Targets submit events.
227
+ on.focusin() // Targets focusin events.
228
+ on.focusout() // Targets focusout events.
229
+ on.scroll() // Targets scroll events.
230
+ on.dblclick() // Targets dblclick events.
231
+ on.pointerdown() // Targets pointerdown events.
232
+ on.pointerup() // Targets pointerup events.
233
+ on.pointermove() // Targets pointermove events.
234
+ on.dragstart() // Targets dragstart events.
235
+ on.drag() // Targets drag events.
236
+ on.dragend() // Targets dragend events.
237
+ on.drop() // Targets drop events.
238
+ on.dragover() // Targets dragover events.
239
+ on.keydown().key("Enter") // Targets keydown events. Under the key, we can either provide a string, or an array of strings to match keys.
240
+ on.select() // Allows you to specify a target definition and returns the selection from the DOM driver.
241
+ on.sources // Returns the sources object passed in from the drivers.
242
+ ```
243
+
244
+ In terms of target definitions, there are standard semantics:
245
+
246
+ ```JavaScript
247
+ {
248
+ intent: "some-intent" // Targets an element with data-intent="some-intent"
249
+ }
250
+
251
+ {
252
+ field: "some-field" // Targets an element with data-field="some-field"
253
+ }
254
+
255
+ {
256
+ id: "some-id" // Targets an element with data-id="some-id"
257
+ }
258
+
259
+ {
260
+ index: "0" // Targets an element with data-index="0"
261
+ }
262
+
263
+ {
264
+ object: "some-object" // Targets an element with data-object="some-object"
265
+ }
266
+
267
+ {
268
+ accept: "some-accept" // Targets an element with data-accept="some-accept"
269
+ }
270
+ ```
271
+
272
+ You can even provide strings as literal query strings.
273
+
274
+ Targeting parents and children is possible too, by defining/nesting `parent` or `child` to other target definitions.
275
+
276
+ In summary, for a complex example, this is what a target definition/intent helper translates to:
277
+
278
+ ```JavaScript
279
+
280
+ on.click({
281
+ intent: "increment-count",
282
+ parent: "main",
283
+ })
284
+
285
+ // Is equivalent to...
286
+
287
+ sources.DOM.select("main [data-intent='increment-count']").events("click").filter((event) => event.currentTarget.ariaDisabled !== "true")
288
+ ```
289
+
290
+ Furthermore, when writing intents, either they can terminate in streams, or nested objects, or arrays of streams.
291
+
292
+ For our application, defining the one intent is sufficient.
293
+
294
+ #### Model Semantics
295
+
296
+ Next, a model can be created to translate intents into state changes.
297
+
298
+ We can create our model at `/src/routes/index/index.model.js`.
299
+
300
+ ```JavaScript
301
+ // index.model.js
302
+
303
+ export default (change) => ({
304
+ increment: ({state, action, global}) => {
305
+ const c = change();
306
+ const current_count = c.at("count").get(state);
307
+ c.at("count").set(current_count + 1);
308
+ return c;
309
+ },
310
+ });
311
+ ```
312
+
313
+ As you can see, our model for this application is trivial.
314
+
315
+ The model structure mirrors the intent. So whenever any intent fires, the corresponding model reducer definition is executed.
316
+
317
+ Models should export a function that returns an object containing its model definition. It accepts a parameter of `change`, which is a factory function for instantiating a change description to immutably modify state.
318
+
319
+ For our corresponding model to intent, `increment`, we supply a function that takes in a destructured object of `state`, `action`, and `global`.
320
+
321
+ The `state` parameter is the route's current state.
322
+
323
+ The `action` parameter is the value returned by the intent stream.
324
+
325
+ The `global` parameter is the app-level current state.
326
+
327
+ We initialize our change description by setting a variable to `change()`. Our model reducer should return that value. Conventionally, we can call this `c`.
328
+
329
+ And then we can apply any business logic and make changes to our state. We wish to do so immutably, so something like `state.count++` would go against the grain of our pattern and potentially introduce hard-to-debug bugs.
330
+
331
+ So we immutably describe our changes and Unicycle takes care of applying them.
332
+
333
+ Our change operator has three main functions: `get`, `set`, and `delete` (as well as `at`).
334
+
335
+ First, we call the `at` function to specify a path to the state we wish to modify.
336
+
337
+ It can either be a string that can be dot-separated to target nested values. Or it can be an array of strings. For instance, `c.at("nested.field")` is equal to `c.at(["nested", "field"])`, which targets `{nested: {field: "This Value"}}`.
338
+
339
+ Then, we can use an operator to describe our change. The `get` operator takes in the object we wish to traverse (which is `state` in this case) for the value we want to know. This is just an accessor function, not a modification function.
340
+
341
+ The `set` operator overwrites the value of the path to the one we supply. In this case, we're changing `count` to the new value.
342
+
343
+ Finally, the `delete` operator removes the field from the state. It takes no parameters.
344
+
345
+ A note on nested intents, if we have an intent such as:
346
+
347
+ ```JavaScript
348
+ {
349
+ ...
350
+ edit: {
351
+ first_name: on.input({
352
+ field: "first_name",
353
+ }).map((event) => event.target.value),
354
+ },
355
+ ...
356
+ }
357
+ ```
358
+
359
+ We can represent the model as such:
360
+
361
+ ```JavaScript
362
+ {
363
+ ...
364
+ edit: {
365
+ _: ({state, action, global}) => {...},
366
+ first_name: ({state, action, global}) => {...},
367
+ },
368
+ ...
369
+ }
370
+ ```
371
+
372
+ The `_` function is a special function that allows in nested intents, for the parent intent to fire any time a child intent fires. In this example, if the `edit.first_name` intent fires, the `_` fires for any nested `edit` intent.
373
+
374
+ In summary, model definitions allow us to define changes to state in a structured way that mirrors the intent definition, and terminates in functions that return change descriptions.
375
+
376
+ #### Route Features
377
+
378
+ As an aside, there may be cases in real applications where the intent and model files grow where it makes sense to split them apart. This is what route features accomplish.
379
+
380
+ So we could have expressed our cookie clicker as a feature instead.
381
+
382
+ Features actually co-locate related intents and models. Features are placed under the route's folder.
383
+
384
+ So we can make a `/src/routes/index/features` folder and put an `increment.js` file as our increment feature.
385
+
386
+ ```JavaScript
387
+ // increment.js
388
+
389
+ export default () => ({
390
+ intent: (on, global$) => ({
391
+ increment: on.click({
392
+ intent: "increment-count",
393
+ }),
394
+ }),
395
+ model: (change) => ({
396
+ increment: ({state, action, global}) => {
397
+ const c = change();
398
+ const current_count = c.at("count").get(state);
399
+ c.at("count").set(current_count + 1);
400
+ return c;
401
+ },
402
+ }),
403
+ });
404
+ ```
405
+
406
+ It exports a function that returns an object which contains the intent and model definitions. The semantics for intents and models are the same here, and it combines it with the route's intent and model structure.
407
+
408
+ #### Route View
409
+
410
+ Finally, we can render our view. The view translates state into the visual interface.
411
+
412
+ We can create it at `/src/routes/index/index.view.js`.
413
+
414
+ ```JavaScript
415
+ // index.view.js
416
+ import { article, button } from "@cycle/dom";
417
+
418
+ export default (state, global) => article([
419
+ button({
420
+ dataset: {
421
+ intent: "increment-count",
422
+ },
423
+ }, `This button was clicked ${state.count} time${state.count !== 1 ? "s" : ""}`),
424
+ ]);
425
+ ```
426
+
427
+ As you can see, the view should export a function that takes in `state` and `global`, which are the route's state and app's state respectively, and returns virtual DOM.
428
+
429
+ Our example is simple just returning a button showing how many times it was clicked.
430
+
431
+ And remember, this is combined with our app-level view we created earlier.
432
+
433
+ #### Route Sinks
434
+
435
+ If we had other drivers, we can also create other sink files. These should export functions that return streams.
436
+
437
+ So typically, it could take this structure:
438
+
439
+ ```JavaScript
440
+ import xs from "xstream";
441
+
442
+ export default ({on, action$, state$, global$} = {}) => xs.merge(
443
+ // ... Your streams
444
+ );
445
+ ```
446
+
447
+ The `on` function is the same from the intent.
448
+
449
+ The `action$` stream is the stream of actions coming from your intents.
450
+
451
+ The `state$` stream is the stream of your route's state.
452
+
453
+ The `global$` stream is the stream of your app's state.
454
+
455
+ ## Wrapping it Up
456
+
457
+ This is a lightweight model-view-intent framework that brings structure to Cycle.js applications and allows them to be written with consistent semantics.
458
+
459
+ We hope this simple framework can serve you and your projects well by providing conventions and structure on top of what Vite and Cycle.js already offer.
460
+
461
+ Made with ❤️ by Uplift Systems Inc.
462
+
463
+ Copyright (c) 2025-2026 Uplift Systems Inc. All rights reserved.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "unicycle",
3
+ "version": "1.0.0",
4
+ "description": "Model-view-intent framework built on top of Vite and Cycle.js.",
5
+ "type": "module",
6
+ "author": "Uplift Systems Inc.",
7
+ "license": "MIT",
8
+ "main": "./src/index.js",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/Uplift-Systems/unicycle.git"
12
+ },
13
+ "homepage": "https://github.com/Uplift-Systems/unicycle#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/Uplift-Systems/unicycle/issues"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "import": "./src/index.js"
20
+ },
21
+ "./vite": {
22
+ "import": "./src/plugin.js"
23
+ }
24
+ },
25
+ "peerDependencies": {
26
+ "@cycle/dom": "^23.1.0",
27
+ "@cycle/history": "^9.0.0",
28
+ "@cycle/run": "^5.7.0",
29
+ "xstream": "^11.14.0"
30
+ }
31
+ }
@@ -0,0 +1,14 @@
1
+ export default function makeTitleDriver() {
2
+ function titleDriver(title$) {
3
+ title$.addListener({
4
+ next: (title) => {
5
+ if (typeof title === "string") {
6
+ document.title = title;
7
+ }
8
+ },
9
+ error: () => {},
10
+ complete: () => {},
11
+ });
12
+ };
13
+ return titleDriver;
14
+ };
package/src/index.js ADDED
@@ -0,0 +1,319 @@
1
+ import { makeDOMDriver } from "@cycle/dom";
2
+ import { makeHistoryDriver } from "@cycle/history";
3
+ import makeTitleDriver from "./drivers/title.driver.js";
4
+ import run from "@cycle/run";
5
+ import xs from "./utils/xs.js";
6
+ import sampleCombineImport from "xstream/extra/sampleCombine";
7
+ const sampleCombine = sampleCombineImport?.default ?? sampleCombineImport;
8
+ import { detailsFromFilePath } from "./utils/detailsFromFilePath.js";
9
+ import { routeMatches } from "./utils/routeMatches.js";
10
+ import { routeParameters } from "./utils/routeParameters.js";
11
+ import { routeQuery } from "./utils/routeQuery.js";
12
+ import { intentToActions } from "./mvi/intentToActions.js";
13
+ import { applyReducers } from "./mvi/applyReducers.js";
14
+ import { applyChanges } from "./mvi/applyChanges.js";
15
+ import { mergeIntents } from "./mvi/mergeIntents.js";
16
+ import { mergeModels } from "./mvi/mergeModels.js";
17
+ import { on } from "./mvi/on.js";
18
+ import { change } from "./mvi/change.js";
19
+
20
+ const clientConfig = Object.values(
21
+ import.meta.glob("/src/app/app.client.js", {
22
+ eager: true,
23
+ })
24
+ )?.[0]?.default ?? (() => ({}));
25
+
26
+ if (typeof clientConfig !== "function") {
27
+ throw new TypeError("Default export for app client configuration at /src/app/app.client.js is expected to be a function.");
28
+ }
29
+
30
+ const config = clientConfig();
31
+
32
+ const drivers = {
33
+ DOM: makeDOMDriver(document.body),
34
+ history: makeHistoryDriver(),
35
+ title: makeTitleDriver(),
36
+ ...(config?.drivers ?? {}),
37
+ };
38
+
39
+ const defaults = {
40
+ DOM: xs.of(undefined),
41
+ history: xs.empty(),
42
+ title: xs.of("Application"),
43
+ ...(config?.defaults ?? {}),
44
+ };
45
+
46
+ const app = {
47
+ sinks: {},
48
+ };
49
+
50
+ for (const [path, d] of Object.entries(import.meta.glob("/src/app/*.js", {eager: true}))) {
51
+
52
+ const { type } = detailsFromFilePath(path);
53
+ const data = d?.default;
54
+
55
+ if (typeof data !== "function") {
56
+ throw new TypeError(`Default export for ${type} for app at ${path} is expected to be a function.`);
57
+ }
58
+
59
+ if (["intent", "model", "view"].includes(type)) {
60
+ app[type] = data;
61
+ } else if (type !== "client") {
62
+ app.sinks[type] = data;
63
+ }
64
+
65
+ }
66
+
67
+ const features = {};
68
+
69
+ for (const [path, feature] of Object.entries(import.meta.glob("/src/routes/*/features/*.js", {eager: true}))) {
70
+
71
+ const parts = path.split("/");
72
+ const filename = parts.at(-1);
73
+ const name = filename.split(".").at(-2);
74
+ const route = parts.at(-3);
75
+ const data = feature?.default;
76
+
77
+ if (typeof data !== "function") {
78
+ throw new TypeError(`Default export for feature ${name} in ${route} route at ${path} is expected to be a function.`);
79
+ }
80
+
81
+ if (!(route in features)) {
82
+ features[route] = {};
83
+ }
84
+
85
+ features[route][name] = data();
86
+
87
+ }
88
+
89
+ const routes = {};
90
+
91
+ for (const [path, route] of Object.entries(import.meta.glob("/src/routes/*/*.js", {eager: true}))) {
92
+
93
+ const { id, type } = detailsFromFilePath(path);
94
+ const data = route?.default;
95
+
96
+ if (typeof data !== "function") {
97
+ throw new TypeError(`Default export for ${type} for ${id} route at ${path} is expected to be a function.`);
98
+ }
99
+
100
+ if (!(id in routes)) {
101
+ routes[id] = {
102
+ id,
103
+ sinks: {},
104
+ };
105
+ }
106
+
107
+ if (type === "route") {
108
+ routes[id].config = data();
109
+ } else if (["intent", "model", "view"].includes(type)) {
110
+ routes[id][type] = data;
111
+ } else {
112
+ routes[id].sinks[type] = data;
113
+ }
114
+
115
+ }
116
+
117
+ for (const route of Object.values(routes)) {
118
+
119
+ route.render = (sources, {appState$}) => {
120
+
121
+ const sinks = {};
122
+
123
+ const routeFeatures = features?.[route.id] ?? {};
124
+
125
+ const action$ = intentToActions(
126
+ Object.entries(routeFeatures).reduce((intents, [_, feature]) => mergeIntents(
127
+ intents,
128
+ (feature?.intent ?? (() => ({})))(on(sources), appState$)
129
+ ),
130
+ (route?.intent ?? (() => ({})))(on(sources), appState$))
131
+ );
132
+
133
+ const state$ = action$.compose(sampleCombine(appState$)).fold((state, [axn, global]) => {
134
+
135
+ const previous = route?.state ?? state;
136
+ const operations = applyReducers(
137
+ Object.entries(routeFeatures).reduce((models, [_, feature]) => mergeModels(
138
+ models,
139
+ (feature?.model ?? (() => ({})))(change)
140
+ ),
141
+ (route?.model ?? (() => ({})))(change)
142
+ ),
143
+ previous,
144
+ axn,
145
+ global,
146
+ );
147
+ const next = applyChanges(previous, operations);
148
+
149
+ route.state = next;
150
+
151
+ return next;
152
+
153
+ }, route?.state ?? route?.config?.state ?? {});
154
+
155
+ const vdom$ = xs.combine(state$, appState$).map(([state, global]) => {
156
+ return (route?.view ?? (() => undefined))(state, global);
157
+ });
158
+
159
+ sinks.DOM = vdom$;
160
+
161
+ for (const [sink, fn] of Object.entries(route.sinks)) {
162
+ const sink$ = fn({
163
+ on: on(sources),
164
+ action$,
165
+ state$,
166
+ global$: appState$,
167
+ });
168
+ sinks[sink] = sink$;
169
+ }
170
+
171
+ return sinks;
172
+
173
+ };
174
+
175
+ }
176
+
177
+ const getIDFromPathname = (pathname) => {
178
+
179
+ for (const [id, route] of Object.entries(routes)) {
180
+
181
+ const path = route?.config?.path ?? "";
182
+
183
+ if ((Array.isArray(path) ? path.some((p) => routeMatches(pathname, p)) : routeMatches(pathname, path))) {
184
+ return id;
185
+ }
186
+
187
+ }
188
+
189
+ };
190
+
191
+ const render = (sources, {appAction$, appState$} = {}) => sources.history.map((history) => {
192
+
193
+ const { pathname } = history;
194
+
195
+ const id = getIDFromPathname(pathname);
196
+
197
+ const route = routes[id] ?? {
198
+ render: () => ({}),
199
+ };
200
+
201
+ const sinks = {
202
+ ...defaults,
203
+ ...route.render({
204
+ ...sources,
205
+ }, {
206
+ appState$,
207
+ }),
208
+ };
209
+
210
+ const vdom$ = xs.combine(sinks.DOM, appState$).map(([dom, global]) => {
211
+ return (app?.view ?? ((dom) => dom ?? "Page not found."))(dom, global);
212
+ });
213
+
214
+ for (const [sink, fn] of Object.entries(app.sinks)) {
215
+ const sink$ = fn({
216
+ on: on(sources),
217
+ action$: appAction$,
218
+ state$: appState$,
219
+ });
220
+ sinks[sink] = xs.merge(
221
+ ...(sink in sinks ? [
222
+ sinks[sink],
223
+ ] : []),
224
+ sink$,
225
+ );
226
+ }
227
+
228
+ return {
229
+ ...sinks,
230
+ DOM: vdom$,
231
+ };
232
+
233
+ });
234
+
235
+ app.render = (() => {
236
+
237
+ const streams = {};
238
+
239
+ return (sources) => {
240
+
241
+ if (streams?.action$ === undefined) {
242
+ streams.action$ = intentToActions({
243
+ route: sources.history,
244
+ ...(app?.intent ?? (() => ({})))(on(sources)),
245
+ });
246
+ }
247
+
248
+ if (streams?.state$ === undefined) {
249
+ streams.state$ = streams.action$.fold((state, axn) => {
250
+
251
+ const operations = applyReducers(
252
+ {
253
+ route: ({action}) => {
254
+ const c = change();
255
+ const route_id = getIDFromPathname(action.pathname);
256
+ const path = route_id ? (Array.isArray(routes?.[route_id]?.config?.path) ? routes[route_id].config.path.find((p) => routeMatches(action.pathname, p)) : routes[route_id].config.path) : action.pathname;
257
+ c.at(["route_id"]).set(route_id ?? null);
258
+ c.at(["pathname"]).set(action.pathname);
259
+ c.at(["parameters"]).set(routeParameters(action.pathname, path));
260
+ c.at(["query"]).set(routeQuery(action.search));
261
+ return c;
262
+ },
263
+ ...(app?.model ?? (() => ({})))(change),
264
+ },
265
+ state,
266
+ axn,
267
+ state,
268
+ );
269
+ return applyChanges(state, operations);
270
+
271
+ }, {
272
+ route_id: null,
273
+ pathname: "/",
274
+ parameters: {},
275
+ query: {},
276
+ ...(config?.state ?? {}),
277
+ }).remember();
278
+ }
279
+
280
+ return {
281
+ appAction$: streams.action$,
282
+ appState$: streams.state$,
283
+ };
284
+
285
+ };
286
+
287
+ })();
288
+
289
+ const application = (sources) => {
290
+
291
+ const sinks = {};
292
+
293
+ const { appAction$, appState$ } = app.render(sources);
294
+
295
+ const route$ = render(sources, {appAction$, appState$});
296
+
297
+ for (const sink of Object.keys(defaults)) {
298
+
299
+ if (sink === "history") {
300
+ sinks[sink] = xs.merge(
301
+ route$.map((route) => route[sink]).flatten(),
302
+ sources.DOM.select("a").events("click").filter((event) => {
303
+ event.preventDefault();
304
+ return event.currentTarget.ariaDisabled !== "true" && event.currentTarget.href !== "";
305
+ }).map((event) => {
306
+ return event.currentTarget.href;
307
+ }),
308
+ );
309
+ } else {
310
+ sinks[sink] = route$.map((route) => route[sink]).flatten();
311
+ }
312
+
313
+ }
314
+
315
+ return sinks;
316
+
317
+ };
318
+
319
+ run(application, drivers);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Returns a new object given an operation.
3
+ * @param {Object} object - Object for operations to be applied to.
4
+ * @param {Object} operation - An operation describing a single mutation.
5
+ * @returns {Object} The resulting object after patching the operation.
6
+ */
7
+ const applyOperation = (object, operation = {}) => {
8
+
9
+ const { type, path, value } = operation;
10
+
11
+ if (Array.isArray(path) && path.length === 0) {
12
+
13
+ if (type === "set") {
14
+ return value;
15
+ }
16
+
17
+ return object;
18
+
19
+ }
20
+
21
+ const [key, ...rest] = path;
22
+
23
+ if (type === "delete" && rest.length === 0) {
24
+ if (!object) {
25
+ return object;
26
+ }
27
+
28
+ const {
29
+ [key]: _,
30
+ ...restObject
31
+ } = object;
32
+
33
+ return restObject;
34
+ }
35
+
36
+ return {
37
+ ...object,
38
+ [key]: applyOperation(object?.[key], {
39
+ type,
40
+ path: rest,
41
+ value,
42
+ }),
43
+ };
44
+
45
+ };
46
+
47
+ /**
48
+ * Applies a collection of operations to state to get new state.
49
+ * @param {Object} state - The state to be changed.
50
+ * @param {Array} operations - A collection of operations to be applied.
51
+ * @returns {Object} New state after applying the collection of operations.
52
+ */
53
+ const applyChanges = (state, operations) => operations.reduce((s, operation) => applyOperation(s, operation), state);
54
+
55
+ export { applyChanges };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Recursively walks a model and calls reducer functions given an action and accumulates changes.
3
+ * @param {Object} model - A model containing reducers.
4
+ * @param {*} state - The current state.
5
+ * @param {*} action - The action being applied.
6
+ * @param {*} global - The global state.
7
+ * @returns {Array} An array of changes as supplied by the model and action.
8
+ */
9
+ const applyReducers = (model, state, action, global) => {
10
+
11
+ const ops = [];
12
+
13
+ const walk = (node, axn) => {
14
+
15
+ if (typeof node === "function") {
16
+ const result = node({
17
+ state,
18
+ action: axn,
19
+ global
20
+ });
21
+ if (Array.isArray(result?.operations)) {
22
+ ops.push(...result.operations);
23
+ return;
24
+ }
25
+ }
26
+
27
+ if (!node || typeof node !== "object") {
28
+ return;
29
+ }
30
+
31
+ if (typeof node?._ === "function") {
32
+ const result = node._({
33
+ state,
34
+ action: axn,
35
+ global
36
+ });
37
+ if (Array.isArray(result?.operations)) {
38
+ ops.push(...result.operations);
39
+ }
40
+ }
41
+
42
+ for (const key in axn) {
43
+ if (key === "_" || !node[key]) {
44
+ continue;
45
+ }
46
+
47
+ walk(node[key], axn[key]);
48
+ }
49
+
50
+ };
51
+
52
+ walk(model, action);
53
+
54
+ return ops;
55
+
56
+ };
57
+
58
+ export { applyReducers };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A factory to describe state changes.
3
+ * @returns {object} An instance of change operations and operators.
4
+ */
5
+ const change = () => {
6
+ const operations = [];
7
+
8
+ return {
9
+ /**
10
+ * Returns operators targeting a field path.
11
+ * @param {Array|string} path - A path to the desired field for modification.
12
+ * @returns {object} Operators.
13
+ */
14
+ at: (path) => {
15
+ const _path = Array.isArray(path) ? path : typeof path === "string" ? path.split(".") : undefined;
16
+ if (!Array.isArray(_path)) {
17
+ throw new TypeError("Invalid change path. Must be either an array of strings or a string.");
18
+ }
19
+ return {
20
+ set: (value) => operations.push({
21
+ type: "set",
22
+ path: _path,
23
+ value,
24
+ }),
25
+ delete: (value) => operations.push({
26
+ type: "delete",
27
+ path: _path,
28
+ }),
29
+ get: (state) => {
30
+
31
+ const walk = (node, path) => {
32
+
33
+ if (node === undefined) {
34
+ return node;
35
+ }
36
+
37
+ if (Array.isArray(path) && path.length === 0) {
38
+ return node;
39
+ }
40
+
41
+ const [key, ...rest] = path;
42
+
43
+ return walk(node?.[key], rest);
44
+
45
+ };
46
+
47
+ return walk(state, path);
48
+
49
+ },
50
+ };
51
+ },
52
+ operations,
53
+ };
54
+ };
55
+
56
+ export { change };
@@ -0,0 +1,44 @@
1
+ import xs from "../utils/xs.js";
2
+ import { isStream } from "./isStream.js";
3
+
4
+ /**
5
+ * Transforms intentions into a single stream of actions to be reduced.
6
+ * @param {object} intentions Key-value pairs of intents and their streams, sub-intentions, or array of streams.
7
+ * @returns {Stream} Stream of actions.
8
+ */
9
+ const intentToActions = (intentions = {}) => {
10
+
11
+ if (typeof intentions !== "object" || intentions === null) {
12
+ throw new TypeError("Intentions must be an object.");
13
+ }
14
+
15
+ return xs.merge(
16
+ ...Object.entries(intentions).map(([intent, affordance]) => {
17
+
18
+ if (isStream(affordance)) {
19
+
20
+ return affordance.map((value) => ({
21
+ [intent]: value,
22
+ }));
23
+
24
+ } else if (Array.isArray(affordance) && affordance.every(isStream)) {
25
+
26
+ return xs.merge(...affordance).map((value) => ({
27
+ [intent]: value,
28
+ }));
29
+
30
+ } else if (typeof affordance === "object" && affordance !== null) {
31
+
32
+ return intentToActions(affordance).map((value) => ({
33
+ [intent]: value,
34
+ }));
35
+
36
+ }
37
+
38
+ return xs.empty();
39
+
40
+ }),
41
+ );
42
+
43
+ };
44
+ export { intentToActions };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Determines whether the input value is a stream.
3
+ * @param {*} value - The value to test whether it is a stream.
4
+ * @returns {Boolean} Whether the input value is a stream.
5
+ */
6
+ const isStream = (value) => typeof value === "object" && typeof value?.addListener === "function" && typeof value?.removeListener === "function" && typeof value?.map === "function";
7
+ export { isStream };
@@ -0,0 +1,29 @@
1
+ import { isStream } from "./isStream.js";
2
+
3
+ /**
4
+ * Merges two intent definitions.
5
+ * @param {Object} a - Intent object.
6
+ * @param {Object} b - Intent object.
7
+ * @returns {Object} Merged intents.
8
+ */
9
+ const mergeIntents = (a = {}, b = {}) => {
10
+
11
+ const result = {
12
+ ...a,
13
+ };
14
+
15
+ for (const [key, value] of Object.entries(b)) {
16
+
17
+ if (Array.isArray(value) || isStream(value)) {
18
+ result[key] = value;
19
+ } else {
20
+ result[key] = mergeIntents(a?.[key] ?? {}, value);
21
+ }
22
+
23
+ }
24
+
25
+ return result;
26
+
27
+ };
28
+
29
+ export { mergeIntents };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Merges two model definitions.
3
+ * @param {Object} a - Model definition.
4
+ * @param {Object} b - Model definition.
5
+ * @returns {Object} - Merged model definition.
6
+ */
7
+ const mergeModels = (a = {}, b = {}) => {
8
+
9
+ const result = {
10
+ ...a,
11
+ };
12
+
13
+ for (const [key, value] of Object.entries(b)) {
14
+
15
+ if (typeof value === "function") {
16
+ result[key] = value;
17
+ } else {
18
+ result[key] = mergeModels(a?.[key] ?? {}, value);
19
+ }
20
+
21
+ }
22
+
23
+ return result;
24
+
25
+ };
26
+
27
+ export { mergeModels };
package/src/mvi/on.js ADDED
@@ -0,0 +1,99 @@
1
+ const TARGETS = ["intent", "field", "id", "index", "object", "accept"];
2
+
3
+ /**
4
+ * Recursively converts DOM selection query (target) objects into a query selector string.
5
+ * @param {string|object} target Either a literal string selector, or an object specifying an intent, field, id, parent, or child.
6
+ * @returns {string} Returns the literal HTML query selector.
7
+ */
8
+ const query = (target) => {
9
+
10
+ const result = [];
11
+
12
+ if (typeof target === "string") {
13
+ return target;
14
+ } else if (typeof target === "object") {
15
+
16
+ for (const type of TARGETS) {
17
+
18
+ if (type in target) {
19
+ const value = target[type];
20
+ result.push(`[data-${type}="${value}"]`);
21
+ }
22
+
23
+ }
24
+
25
+ if ("inert" in target) {
26
+ if (target["inert"] === true) {
27
+ result.push("[inert]");
28
+ } else if (target["inert"] === false) {
29
+ result.push(":not([inert])");
30
+ }
31
+ }
32
+
33
+ if ("child" in target) {
34
+ return [result.join(""), query(target.child)].join(" ");
35
+ } else if ("parent" in target) {
36
+ return [query(target.parent), result.join("")].join(" ");
37
+ }
38
+
39
+ }
40
+
41
+ return result.join("");
42
+
43
+ };
44
+
45
+ const select = (sources, target) => sources.DOM.select(query(target));
46
+
47
+ /**
48
+ * Determines whether an event's target is enabled aria-wise.
49
+ * @param {Event} event An event object.
50
+ * @returns {Boolean} Whether the target is enabled or not via aria-disabled.
51
+ */
52
+ const isEnabled = (event) => event.currentTarget.ariaDisabled !== "true";
53
+
54
+ /**
55
+ * Prevents default on event.
56
+ * @param {Event} event An event object.
57
+ * @returns {Event} The event.
58
+ */
59
+ const preventDefault = (event) => {
60
+ event.preventDefault();
61
+ return event;
62
+ };
63
+
64
+ /**
65
+ * Determines whether one is typing in the active element during a particular event.
66
+ * @param {Event} event An event object.
67
+ * @returns {Boolean} Whether one is typing during the event.
68
+ */
69
+ const isTyping = (event) => {
70
+ const element = document.activeElement;
71
+
72
+ if (!element) {
73
+ return false;
74
+ }
75
+
76
+ return (element.tagName === "TEXTAREA" || element.tagName === "INPUT" || element.isContentEditable);
77
+ };
78
+
79
+ /**
80
+ * Determines whether one is not typing in the active element during a particular event.
81
+ * @param {Event} event An event object.
82
+ * @returns {Boolean} Whether one is not typing during the event.
83
+ */
84
+ const isNotTyping = (event) => !isTyping(event);
85
+
86
+ const on = (sources) => ({
87
+ click: (target) => select(sources, target).events("click").filter(isEnabled),
88
+ ...["change", "input", "submit", "focusin", "focusout", "scroll", "dblclick", "pointerdown", "pointerup", "pointermove", "dragstart", "drag", "dragend", "drop", "dragover"].reduce((events, event) => ({
89
+ ...events,
90
+ [event]: (target) => select(sources, target).events(event),
91
+ }), {}),
92
+ keydown: (target) => ({
93
+ key: (key) => select(sources, target).events("keydown").filter((event) => Array.isArray(key) ? key.includes(event.key) : key === event.key),
94
+ }),
95
+ select: (target) => select(sources, target),
96
+ sources,
97
+ });
98
+
99
+ export { on };
package/src/plugin.js ADDED
@@ -0,0 +1,25 @@
1
+ function UnicyclePlugin() {
2
+ return {
3
+ name: "unicycle",
4
+ transformIndexHtml: {
5
+ order: "pre",
6
+ handler(html) {
7
+ return {
8
+ html,
9
+ tags: [
10
+ {
11
+ tag: "script",
12
+ attrs: {
13
+ type: "module",
14
+ },
15
+ injectTo: "head",
16
+ children: `import "unicycle";`,
17
+ },
18
+ ],
19
+ };
20
+ }
21
+ },
22
+ };
23
+ };
24
+
25
+ export default UnicyclePlugin;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Converts a file path to its id and type.
3
+ * @param {string} path - A file path.
4
+ * @returns {object} An object containing an id and type.
5
+ * @example
6
+ * const { id, type } = detailsFromFilePath("/src/routes/dashboard/dashboard.view.js");
7
+ * console.log(id); // Outputs "dashboard".
8
+ * console.log(type); // Outputs "view".
9
+ */
10
+ const detailsFromFilePath = (path) => {
11
+
12
+ if (typeof path === "string") {
13
+
14
+ const parts = path.split("/");
15
+ const id = parts.at(-2);
16
+ const filename = parts.at(-1);
17
+ const type = filename.split(".").at(-2);
18
+
19
+ return {
20
+ id,
21
+ type,
22
+ }
23
+
24
+ }
25
+
26
+ return {};
27
+
28
+ };
29
+
30
+ export { detailsFromFilePath };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Determines whether the pathname matches the supplied path pattern.
3
+ * @param {string} pathname - The literal history path.
4
+ * @param {string} path - The path pattern to match.
5
+ * @returns {Boolean} Whether the pathname matches the supplied path.
6
+ */
7
+ const routeMatches = (pathname, path) => {
8
+
9
+ const names = pathname.split("/").filter((part, i, parts) => !((i === 0 || i === parts.length - 1) && part === ""));
10
+
11
+ const paths = path.split("/").filter((part, i, parts) => !((i === 0 || i === parts.length - 1) && part === ""));
12
+
13
+ if (names.length !== paths.length) {
14
+ return false;
15
+ }
16
+
17
+ for (const [index, slug] of paths.entries()) {
18
+
19
+ if (names[index] === undefined) {
20
+ return false;
21
+ }
22
+
23
+ if (names[index] !== slug && !slug.startsWith(":")) {
24
+ return false;
25
+ }
26
+
27
+ }
28
+
29
+ return true;
30
+
31
+ };
32
+
33
+ export { routeMatches };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Gets route parameters from pathname and path pattern.
3
+ * @param {string} pathname - The history pathname.
4
+ * @param {string} path - A path pattern.
5
+ * @returns {object} An object with route parameters from path pattern.
6
+ */
7
+ const routeParameters = (pathname, path) => {
8
+
9
+ const result = {};
10
+
11
+ const names = pathname.split("/").filter((part, i, parts) => !((i === 0 || i === parts.length - 1) && part === ""));
12
+
13
+ const paths = path.split("/").filter((part, i, parts) => !((i === 0 || i === parts.length - 1) && part === ""));
14
+
15
+ for (const [index, slug] of paths.entries()) {
16
+
17
+ if (names[index] !== undefined && slug.startsWith(":")) {
18
+ result[slug.slice(1)] = names[index];
19
+ }
20
+
21
+ }
22
+
23
+ return result;
24
+
25
+ };
26
+
27
+ export { routeParameters };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Converts history query string to key-value object.
3
+ * @param {string} query - The history query string.
4
+ * @returns {object} An object containing route query.
5
+ */
6
+ const routeQuery = (query) => {
7
+
8
+ const result = {};
9
+
10
+ const params = new URLSearchParams(query);
11
+
12
+ for (const [key, value] of params) {
13
+ result[key] = value;
14
+ }
15
+
16
+ return result;
17
+
18
+ };
19
+
20
+ export { routeQuery };
@@ -0,0 +1,4 @@
1
+ import xstream from "xstream";
2
+ const xs = xstream?.default ?? xstream;
3
+
4
+ export default xs;