From cf6c5bcde4cb3e617466d197182ced6df406920c Mon Sep 17 00:00:00 2001 From: levizwannah Date: Mon, 24 Nov 2025 23:30:23 +0300 Subject: [PATCH 01/46] working on modular openscript --- .gitignore | 45 + LICENSE | 21 + README.md | 710 +++ README.npm.md | 218 + bin/create-ojs-app.js | 209 + build/README.md | 93 + build/vite-plugin-openscript.js | 243 + docs/TAILWIND_INTEGRATION.md | 183 + examples/advanced-features.js | 77 + examples/basic-app/contexts.js | 87 + examples/basic-app/events.js | 67 + examples/basic-app/helpers.js | 71 + examples/basic-app/index.html | 21 + examples/basic-app/index.js | 51 + examples/basic-app/pages/TodoApp.js | 183 + examples/basic-app/routes.js | 55 + examples/basic-usage.js | 23 + examples/component-example.js | 26 + examples/context-state-example.js | 195 + examples/event-handling.js | 135 + examples/full-application.js | 334 ++ examples/state-example.js | 371 ++ index.js | 83 - ojs-config.json | 33 - optimizations.md | 25 + package.json | 87 +- postcss.config.js | 6 + src/broker/Broker.js | 275 + src/broker/BrokerRegistrar.js | 79 + src/broker/Listener.js | 14 + src/component/Component.js | 864 ++++ src/component/DOMReconciler.js | 215 + src/component/MarkupEngine.js | 683 +++ src/component/MarkupHandler.js | 39 + src/component/h.js | 3 + src/core/AutoLoader.js | 413 ++ src/core/Context.js | 52 + src/core/ContextProvider.js | 153 + src/core/Emitter.js | 78 + src/core/EventData.js | 104 + src/core/ProxyFactory.js | 14 + src/core/Runner.js | 43 + src/core/State.js | 246 + src/fotastart.js | 918 ---- src/grouper.mjs | 330 -- src/helpers.js | 291 -- src/index.js | 174 + src/mediator/Mediator.js | 57 + src/mediator/MediatorManager.js | 47 + src/open-script.js | 4401 ----------------- src/router/Router.js | 493 ++ src/utils/DOM.js | 173 + src/utils/Utils.js | 264 + src/utils/helpers.js | 20 + styles/tailwind.css | 31 + tailwind.config.js | 45 + templates/basic/.gitignore | 33 + templates/basic/README.md | 42 + templates/basic/index.html | 15 + templates/basic/src/components/App.js | 26 + templates/basic/src/components/Counter.js | 50 + templates/basic/src/contexts.js | 25 + templates/basic/src/events.js | 11 + templates/basic/src/main.js | 21 + templates/basic/src/ojs.config.js | 75 + templates/basic/src/routes.js | 32 + templates/basic/src/style.css | 95 + templates/basic/vite.config.js | 12 + templates/bootstrap/.gitignore | 33 + templates/bootstrap/README.md | 54 + templates/bootstrap/index.html | 25 + templates/bootstrap/src/components/App.js | 55 + templates/bootstrap/src/components/Counter.js | 106 + templates/bootstrap/src/main.js | 18 + templates/bootstrap/src/style.css | 20 + templates/bootstrap/vite.config.js | 12 + templates/tailwind/.gitignore | 33 + templates/tailwind/README.md | 42 + templates/tailwind/index.html | 15 + templates/tailwind/postcss.config.js | 6 + templates/tailwind/src/components/App.js | 25 + templates/tailwind/src/components/Counter.js | 53 + templates/tailwind/src/main.js | 19 + templates/tailwind/src/style.css | 3 + templates/tailwind/tailwind.config.js | 11 + templates/tailwind/vite.config.js | 12 + terser.config.json | 23 - test/Broker.test.js | 95 + test/Component.test.js | 166 + test/Context.test.js | 96 + test/MarkupEngine.test.js | 182 + test/README.md | 54 + test/Router.test.js | 29 + test/State.test.js | 120 + vite.config.js | 58 + vitest.config.js | 49 + 96 files changed, 9928 insertions(+), 6094 deletions(-) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.npm.md create mode 100644 bin/create-ojs-app.js create mode 100644 build/README.md create mode 100644 build/vite-plugin-openscript.js create mode 100644 docs/TAILWIND_INTEGRATION.md create mode 100644 examples/advanced-features.js create mode 100644 examples/basic-app/contexts.js create mode 100644 examples/basic-app/events.js create mode 100644 examples/basic-app/helpers.js create mode 100644 examples/basic-app/index.html create mode 100644 examples/basic-app/index.js create mode 100644 examples/basic-app/pages/TodoApp.js create mode 100644 examples/basic-app/routes.js create mode 100644 examples/basic-usage.js create mode 100644 examples/component-example.js create mode 100644 examples/context-state-example.js create mode 100644 examples/event-handling.js create mode 100644 examples/full-application.js create mode 100644 examples/state-example.js delete mode 100644 index.js delete mode 100644 ojs-config.json create mode 100644 optimizations.md create mode 100644 postcss.config.js create mode 100644 src/broker/Broker.js create mode 100644 src/broker/BrokerRegistrar.js create mode 100644 src/broker/Listener.js create mode 100644 src/component/Component.js create mode 100644 src/component/DOMReconciler.js create mode 100644 src/component/MarkupEngine.js create mode 100644 src/component/MarkupHandler.js create mode 100644 src/component/h.js create mode 100644 src/core/AutoLoader.js create mode 100644 src/core/Context.js create mode 100644 src/core/ContextProvider.js create mode 100644 src/core/Emitter.js create mode 100644 src/core/EventData.js create mode 100644 src/core/ProxyFactory.js create mode 100644 src/core/Runner.js create mode 100644 src/core/State.js delete mode 100644 src/fotastart.js delete mode 100644 src/grouper.mjs delete mode 100644 src/helpers.js create mode 100644 src/index.js create mode 100644 src/mediator/Mediator.js create mode 100644 src/mediator/MediatorManager.js delete mode 100644 src/open-script.js create mode 100644 src/router/Router.js create mode 100644 src/utils/DOM.js create mode 100644 src/utils/Utils.js create mode 100644 src/utils/helpers.js create mode 100644 styles/tailwind.css create mode 100644 tailwind.config.js create mode 100644 templates/basic/.gitignore create mode 100644 templates/basic/README.md create mode 100644 templates/basic/index.html create mode 100644 templates/basic/src/components/App.js create mode 100644 templates/basic/src/components/Counter.js create mode 100644 templates/basic/src/contexts.js create mode 100644 templates/basic/src/events.js create mode 100644 templates/basic/src/main.js create mode 100644 templates/basic/src/ojs.config.js create mode 100644 templates/basic/src/routes.js create mode 100644 templates/basic/src/style.css create mode 100644 templates/basic/vite.config.js create mode 100644 templates/bootstrap/.gitignore create mode 100644 templates/bootstrap/README.md create mode 100644 templates/bootstrap/index.html create mode 100644 templates/bootstrap/src/components/App.js create mode 100644 templates/bootstrap/src/components/Counter.js create mode 100644 templates/bootstrap/src/main.js create mode 100644 templates/bootstrap/src/style.css create mode 100644 templates/bootstrap/vite.config.js create mode 100644 templates/tailwind/.gitignore create mode 100644 templates/tailwind/README.md create mode 100644 templates/tailwind/index.html create mode 100644 templates/tailwind/postcss.config.js create mode 100644 templates/tailwind/src/components/App.js create mode 100644 templates/tailwind/src/components/Counter.js create mode 100644 templates/tailwind/src/main.js create mode 100644 templates/tailwind/src/style.css create mode 100644 templates/tailwind/tailwind.config.js create mode 100644 templates/tailwind/vite.config.js delete mode 100644 terser.config.json create mode 100644 test/Broker.test.js create mode 100644 test/Component.test.js create mode 100644 test/Context.test.js create mode 100644 test/MarkupEngine.test.js create mode 100644 test/README.md create mode 100644 test/Router.test.js create mode 100644 test/State.test.js create mode 100644 vite.config.js create mode 100644 vitest.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a83deb --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# .gitignore for OpenScript + +# Dependencies +node_modules/ +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build outputs +dist/ +build/output/ +*.tsbuildinfo + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Environment files +.env +.env.local +.env.*.local + +# Temporary files +*.tmp +*.temp +.cache/ + +# Test coverage +coverage/ +.nyc_output/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e4cd86 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Levi Kamara Zwannah + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b7cde6 --- /dev/null +++ b/README.md @@ -0,0 +1,710 @@ +# Modular OpenScript Framework + +This is a modularized version of the OpenScript framework, designed to be more maintainable, testable, and scalable. + +## Installation + +You can import the modules directly into your project. + +```javascript +import { Component, h, Runner } from './path/to/modular-openscript/index.js'; +``` + +## Quick Start + +See [`examples/full-application.js`](./examples/full-application.js) for a complete real-world example. + +```javascript +import { Component, Mediator, h, broker, context, putContext, state, router } from './modular-openscript/index.js'; + +// 1. Register events +const $e = { + user: { authenticated: true, loggedOut: true } +}; +broker.registerEvents($e); + +// 2. Initialize context +putContext("user", "UserContext"); +const uc = context("user"); +uc.states({ isLoggedIn: false }); + +// 3. Create a component +class Dashboard extends Component { + render(...args) { + return h.div("Welcome to OpenScript!", ...args); + } +} + +// 4. Mount to DOM +const dashboard = new Dashboard(); +dashboard.mount(document.getElementById("app")); +``` + +## Core Concepts + +### Component + +Components are the building blocks of your UI. They extend the `Component` class. + +```javascript +import { Component, h } from './modular-openscript/index.js'; + +class MyComponent extends Component { + render(...args) { + return h.div('Hello World', ...args); + } +} +``` + +### Runner + +The `Runner` class is used to initialize and mount your components. + +```javascript +import { Runner } from './modular-openscript/index.js'; +import MyComponent from './MyComponent.js'; + +new Runner().run(MyComponent); +``` + +### State + +State management is handled by the `State` class. + +```javascript +import { State } from './modular-openscript/index.js'; + +const myState = State.state(0); + +myState.value++; // Triggers updates +``` + +### Router + +Client-side routing is managed by the `Router` class. + +```javascript +import { router } from './modular-openscript/index.js'; + +router.on('home', () => { + console.log('Home page'); +}); + +router.listen(); +``` + +### Broker + +The `Broker` acts as a central event bus. + +```javascript +import { broker } from './modular-openscript/index.js'; + +broker.on('my-event', (data) => { + console.log(data); +}); + +broker.emit('my-event', { some: 'data' }); +``` + + +## Advanced Features + +### Fragments +Use `h.$` or `h._` to create document fragments, allowing you to return multiple elements without a parent wrapper. + +```javascript +import { Component, h } from './modular-openscript/index.js'; + +class FragmentComponent extends Component { + render(...args) { + return h.$( + h.h3("Header"), + h.p("Paragraph 1"), + h.p("Paragraph 2") + ); + } +} +``` + +### State Management +OpenScript provides a simple reactive state system. + +```javascript +import { Component, h, state } from './modular-openscript/index.js'; + +const counter = state(0); + +class CounterComponent extends Component { + render(...args) { + return h.div( + h.h3(`Count: ${counter.value}`), + h.button({ onclick: () => counter.value++ }, "Increment"), + ...args + ); + } +} +``` + +### Context +Contexts allow you to share state across components. + +```javascript +import { Component, h, context, putContext } from './modular-openscript/index.js'; + +// Register a context +putContext("Theme", "contexts.ThemeContext"); + +class ThemedComponent extends Component { + constructor() { + super(); + this.themeContext = context("Theme"); + } + // ... +} +``` + +> [!WARNING] +> **Deprecation Notice**: `fetchContext` is deprecated. Please use `putContext` instead. `putContext` handles both loading and fetching logic more efficiently. + +## Context Management + +Contexts provide a way to organize and share state across your application. + +### Creating Contexts + +```javascript +import { putContext, context } from './modular-openscript/index.js'; + +// Register contexts +putContext(["global", "user", "page"], "AppContext"); + +// Access contexts +const gc = context("global"); // Global context +const uc = context("user"); // User context +const pc = context("page"); // Page context +``` + +### Using Context States + +```javascript +// Initialize multiple states in a context +uc.states({ + cart: {}, + profile: null, + isLoggedIn: false +}); + +// Access and modify state +uc.isLoggedIn.value = true; + +// Add listeners to context states +uc.cart.listener((cartState) => { + console.log(`Cart updated: ${Object.keys(cartState.value).length} items`); +}); +``` + +**The `.states()` Helper** + +The `.states()` method is a convenient helper that creates multiple state properties on a context at once: + +```javascript +// Instead of: +uc.cart = state({}); +uc.profile = state(null); +uc.isLoggedIn = state(false); + +// You can use: +uc.states({ + cart: {}, + profile: null, + isLoggedIn: false +}); +``` + +Each key becomes a reactive `State` property on the context, automatically created using `state()`. + +### Global State Pattern: Pass States to Components + +**Best Practice**: Define global states in contexts, then pass them to components via the render method: + +```javascript +// In your initialization (e.g., declarations.js) +const pc = context("page"); +pc.states({ + pageTitle: "Home", + loading: false +}); + +// Pass state to component when rendering +h.HomePage(pc.pageTitle, { + parent: document.getElementById("root"), + resetParent: true +}); +``` + +**Component receives state in render:** + +```javascript +class HomePage extends Component { + // State is passed as parameter + render(pageTitle, ...args) { + return h.div( + h.h1(pageTitle.value), // Access via .value + h.p("Welcome to the home page"), + ...args + ); + } +} +``` + +This pattern: +- ✅ Centralizes state management in contexts +- ✅ Makes components reusable and testable +- ✅ Automatically re-renders when state changes +- ✅ Keeps component logic clean + +### Context Properties + +You can also add non-reactive properties to contexts: + +```javascript +gc.appName = "MyApp"; +gc.version = "1.0.0"; +``` + +## Event Handling + +OpenScript provides a powerful event-driven architecture. + +### Event Registration Pattern + +Before using events, register them with the broker. This creates a centralized event catalog: + +```javascript +import { broker } from './modular-openscript/index.js'; + +const $e = { + system: { + booted: true, + needs: { + reload: true, + } + }, + user: { + authenticated: true, + loggedOut: true, + needs: { + login: true, + logout: true, + }, + has: { + loginError: true, + } + } +}; + +// Register all events at application startup +broker.registerEvents($e); + +// Make events globally accessible +window.$e = $e; +``` + +This pattern: +- Provides clear event documentation +- Enables autocomplete in IDEs +- Creates namespaced event names (e.g., `user:authenticated`, `user:needs:login`) + +### Declarative Listening (Mediators) +Use the `$$` prefix in Mediators to automatically register event listeners. Nested objects create namespaced events (e.g., `$$user.login` becomes `user:login`). + +```javascript +import { Mediator, Utils } from './modular-openscript/index.js'; + +class AuthMediator extends Mediator { + $$user = { + login: (ed, event) => { + const data = Utils.parsePayload(ed); + console.log("User logged in", data.message); + } + }; +} +``` + +### Imperative Listening +You can also listen to events directly using the global `broker` instance. + +```javascript +import { broker, Utils } from './modular-openscript/index.js'; + +broker.on("user:login", (ed, event) => { + const data = Utils.parsePayload(ed); + console.log("User logged in", data.message); +}); +``` + +### Emitting Events +Use `broker.send()` or `broker.broadcast()` to emit events. + +```javascript +broker.send("user:login", payload({ username: "Alice" })); +``` + +### Advanced Patterns + +#### Multi-Event Listening +You can listen to multiple events in a single handler by separating them with an underscore `_`. + +```javascript +class UserMediator extends Mediator { + $$user = { + // Triggers on 'user:login' OR 'user:logout' + login_logout: (ed, event) => { + console.log(`Event ${event} triggered`); + } + }; +} +``` + +#### Component Events +Components can listen to events from other components using `h.on`. + +```javascript +import { Component, h } from './modular-openscript/index.js'; +import { LoginButton } from './examples/event-handling.js'; + +class Dashboard extends Component { + render(...args) { + return h.div( + // Listen to the 'rendered' event of LoginButton + h.on(LoginButton, "rendered", () => { + console.log("Login Button rendered"); + }), + h.component(new LoginButton()) + ); + } +} +``` + +#### Component Methods as Listeners +Prefix component methods with `$_` to use them easily as event handlers in your markup. + +```javascript +class MyComponent extends Component { + $_handleClick(e) { + console.log("Clicked!"); + } + + render() { + return h.button({ onclick: this.$_handleClick }, "Click Me"); + } +} +``` + +### Special Attributes + +OpenScript's markup engine recognizes special attributes that control element behavior: + +#### DOM Manipulation Attributes + +**`parent`** (HTMLElement) +- Specifies which parent element to append this element to +```javascript +h.div({ parent: document.getElementById("container") }, "Content") +``` + +**`resetParent`** (boolean) +- When `true`, clears all children from the parent before appending +```javascript +h.div({ parent: container, resetParent: true }, "Replace all content") +``` + +**`firstOfParent`** (boolean) +- When `true`, prepends the element as the first child of its parent +```javascript +h.div({ parent: container, firstOfParent: true }, "I'll be first") +``` + +**`replaceParent`** (boolean) +- When `true`, replaces the parent element entirely with this element +```javascript +h.div({ parent: oldElement, replaceParent: true }, "New content") +``` + +#### Event Attributes + +**`listeners`** (object) +- Attach DOM event listeners; value can be a function or array of functions +```javascript +h.button({ + listeners: { + click: handleClick, + mouseover: [handler1, handler2] + } +}, "Click me") +``` + +**`event`** (string) +- Component event name to emit after rendering +```javascript +h.div({ event: "custom:rendered" }, "Content") +``` + +**`eventParams`** (any | array) +- Parameters to pass with the component event +```javascript +h.div({ + event: "data:loaded", + eventParams: [{ id: 123 }, "extra"] +}, "Content") +``` + +#### Component Attributes + +**`component`** (Component) +- Associates a Component instance with the element +```javascript +h.div({ component: myComponentInstance }, "Wrapper") +``` + +**`c_attr`** (object) +- Custom attributes to pass to the associated component +```javascript +h.div({ c_attr: { userId: 123, role: "admin" } }) +``` + +**`$` prefix** (any) +- Shorthand for component attributes; `$userId` becomes component attribute `userId` +```javascript +h.div({ $userId: 123, $role: "admin" }) +// Equivalent to: c_attr: { userId: 123, role: "admin" } +``` + +**`withCAttr`** (boolean) +- Flag to enable component attribute processing + +**`methods`** (object) +- Methods to attach to the element, accessible via `element.methods()` +```javascript +h.div({ + methods: { + getData: () => ({ id: 1 }), + setData: (data) => console.log(data) + } +}) +``` + +### Helper Functions + +#### h.func() - Inline Event Handlers +Create callable string references for functions with arguments: + +```javascript +class ProductCard extends Component { + render(product) { + return h.button( + { + // h.func creates: "broker.send('cart:add', payload({...}))" + onclick: h.func( + "broker.send", + $e.cart.needs.addition, + payload({ product }) + ) + }, + "Add to Cart" + ); + } +} +``` + +#### component.method() - Component Method Reference +Reference component methods in templates: + +```javascript +class Form extends Component { + submitForm() { + console.log("Submitting..."); + } + + render() { + return h.button( + { onclick: this.method("submitForm") }, + "Submit" + ); + } +} +``` + +## State Management + +OpenScript provides reactive state management through the `state` helper. + +### Automatic State Listening in Components +When you pass state to a component's `render()` method or use it in the render output, the component automatically listens to state changes and re-renders. + +```javascript +import { Component, h, state } from './modular-openscript/index.js'; + +class Counter extends Component { + count = state(0); + + $_increment() { + this.count.value++; + } + + // Component automatically re-renders when this.count changes + render(...args) { + return h.div( + h.p(`Count: ${this.count.value}`), + h.button({ onclick: this.$_increment }, "Increment") + ); + } +} +``` + +### Direct State Listeners +You can also add direct listeners to state using the `.listener()` method. + +```javascript +class MyComponent extends Component { + count = state(0); + + constructor() { + super(); + + // Add a direct listener + this.count.listener((currentState) => { + console.log(`Count is now: ${currentState.value}`); + }); + } + + render(...args) { + return h.div( + h.button( + { onclick: () => this.count.value++ }, + "Increment" + ) + ); + } +} +``` + +### State Methods +- **`.listener(callback)`**: Add a listener that fires when state changes +- **`.once(callback)`**: Add a one-time listener +- **`.off(id)`**: Remove a listener by ID +- **`.value`**: Get or set the state value + +## Application Initialization + +Complete application setup following Carata patterns: + +```javascript +// 1. Define and register events +const $e = { /* event definitions */ }; +broker.registerEvents($e); + +// 2. Initialize contexts +putContext(["global", "user"], "AppContext"); +const uc = context("user"); +uc.states({ cart: {}, isLoggedIn: false }); + +// 3. Set up state listeners +uc.cart.listener((cart) => { + console.log("Cart changed"); +}); + +// 4. Initialize mediators +const cartMediator = new CartMediator(); + +// 5. Set up routing +router.on("/", () => { /* ... */ }, "home"); +router.prefix("products").group(() => { + router.on("/{id}/view", () => { /* ... */ }, "product.view"); +}); + +// 6. Mount components +const dashboard = new Dashboard(); +dashboard.mount(document.getElementById("app")); + +// 7. Broadcast system ready +broker.broadcast($e.system.booted); + +// 8. Start listening to routes +router.listen(); +``` + +## Routing + +OpenScript includes a built-in router for single-page applications using a fluent API: + +```javascript +import { router } from './modular-openscript/index.js'; + +// Simple route +router.on("/", () => { + console.log("Home page"); +}, "home"); + +// Route with parameters +router.on("/users/{id}", () => { + console.log(`User ID: ${router.params.id}`); +}, "user.view"); + +// Grouped routes with prefix +router.prefix("products").group(() => { + router.on("/{productId}/view", () => { + console.log(`Product: ${router.params.productId}`); + }, "product.view"); + + router.on("/create", () => { + console.log("Create product"); + }, "product.create"); +}); + +// Multiple routes to same handler +router.orOn( + ["/login", "/signin"], + () => { + console.log("Login page"); + }, + ["auth.login", "auth.signin"] +); + +// Programmatic navigation (by route name) +router.to("home"); +router.to("user.view", { id: 123 }); + +// Navigation with query strings +router.to("products.view", { productId: 456, tab: "reviews" }); +// Creates: /products/456/view?tab=reviews + +// Start listening to route changes +router.listen(); +``` + +### Router Methods +- **`.on(path, handler, name)`**: Register a route +- **`.orOn(paths, handler, names)`**: Register multiple paths to same handler +- **`.prefix(name)`**: Create a prefix for grouped routes +- **`.group(callback)`**: Group routes under a prefix +- **`.to(nameOrPath, params)`**: Navigate to a route +- **`.listen()`**: Start listening to URL changes +- **`.params`**: Access route parameters +- **`.qs`**: Access query string parameters + +## Directory Structure + + +- `core/`: Core classes like `Runner`, `Emitter`, `State`, `Context`. +- `component/`: UI related classes like `Component`, `MarkupEngine`, `h`. +- `router/`: Routing logic. +- `broker/`: Event bus logic. +- `mediator/`: Business logic mediators. +- `utils/`: Helper functions. +- `examples/`: Usage examples. + +## Optimizations + +See `optimizations.md` for suggested improvements. diff --git a/README.npm.md b/README.npm.md new file mode 100644 index 0000000..f02e0c5 --- /dev/null +++ b/README.npm.md @@ -0,0 +1,218 @@ +# OpenScriptJs + +[![npm version](https://badge.fury.io/js/openscriptjs.svg)](https://www.npmjs.com/package/openscriptjs) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +A lightweight, reactive JavaScript framework for building modern web applications with components, state management, routing, and event-driven architecture. + +## ✨ Features + +- ⚡️ **Reactive State Management** - Built-in reactive state with automatic component re-rendering +- 🧩 **Component-Based** - Modular, reusable components with declarative markup +- 🔄 **Routing** - Fluent client-side router API +- 📡 **Event System** - Broker pattern for decoupled component communication +- 🎯 **Mediators** - Centralized business logic handlers +- 🎨 **TailwindCSS Ready** - First-class Tailwind integration +- 🛠️ **Build Tools** - Vite plugin for minification-safe builds +- 📦 **Zero Dependencies** - Core framework has no runtime dependencies + +## 🚀 Quick Start + +### Installation + +```bash +npm install openscriptjs +``` + +### Create a New Project + +```bash +npm create openscript my-app +cd my-app +npm run dev +``` + +Choose from templates: +- `basic` - Clean starter with vanilla CSS +- `tailwind` - Pre-configured with TailwindCSS + +## 📖 Basic Usage + +```javascript +import { Component, h, state } from 'openscriptjs'; + +class Counter extends Component { + constructor() { + super(); + this.count = state(0); + } + + increment() { + this.count.value++; + } + + render() { + return h.div( + h.h2("Count: ", this.count.value), + h.button({ + listeners: { click: this.increment.bind(this) } + }, "Increment") + ); + } +} + +// Mount and render +const counter = new Counter(); +await counter.mount(); +h.Counter({ parent: document.body }); +``` + +## 🏗️ Project Structure + +``` +my-app/ +├── src/ +│ ├── components/ # Your components +│ ├── main.js # Entry point +│ └── style.css # Styles +├── index.html +├── vite.config.js +└── package.json +``` + +## 📚 Core Concepts + +### Components + +```javascript +import { Component, h } from 'openscriptjs'; + +class MyComponent extends Component { + render(...args) { + return h.div( + { class: "container" }, + h.h1("Hello OpenScript!"), + ...args + ); + } +} +``` + +### State Management + +```javascript +import { state } from 'openscriptjs'; + +// Create reactive state +const count = state(0); + +// Update triggers re-render +count.value = 10; + +// Listen to changes +count.listener((s) => console.log('New value:', s.value)); +``` + +### Routing + +```javascript +import { router, h } from 'openscriptjs'; + +router.on('/home', () => { + h.HomePage({ parent: document.body, resetParent: true }); +}); + +router.on('/about', () => { + h.AboutPage({ parent: document.body, resetParent: true }); +}); + +router.listen(); +``` + +### Context & Global State + +```javascript +import { context, putContext } from 'openscriptjs'; + +// Register contexts +putContext(["global", "user"], "AppContext"); + +const gc = context("global"); + +// Initialize states +gc.states({ + appName: "My App", + theme: "light" +}); + +// Pass to components +h.MyComponent(gc.appName, { parent: document.body }); +``` + +## 🎨 TailwindCSS Integration + +OpenScript works seamlessly with Tailwind: + +```javascript +h.div( + { class: "bg-blue-500 text-white p-4 rounded-lg" }, + h.h1({ class: "text-2xl font-bold" }, "Styled with Tailwind") +) +``` + +See [Tailwind Integration Guide](./docs/TAILWIND_INTEGRATION.md) for details. + +## 🔧 Building Your App + +```bash +# Development +npm run dev + +# Production build +npm run build + +# Preview build +npm run preview +``` + +## 📦 Using the Vite Plugin + +For proper minification handling: + +```javascript +// vite.config.js +import { openScriptComponentPlugin } from 'openscriptjs/plugin'; + +export default { + plugins: [ + openScriptComponentPlugin() + ] +} +``` + +This ensures component names survive minification. + +## 📘 Documentation + +- [Full Documentation](./README.md) +- [API Reference](./docs/) +- [Examples](./examples/) +- [Tailwind Integration](./docs/TAILWIND_INTEGRATION.md) + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## 📄 License + +MIT © Levi Kamara Zwannah + +## 🔗 Links + +- [GitHub Repository](https://github.com/yourusername/openscriptjs) +- [Issue Tracker](https://github.com/yourusername/openscriptjs/issues) +- [npm Package](https://www.npmjs.com/package/openscriptjs) + +--- + +Built with ❤️ using OpenScript diff --git a/bin/create-ojs-app.js b/bin/create-ojs-app.js new file mode 100644 index 0000000..735a667 --- /dev/null +++ b/bin/create-ojs-app.js @@ -0,0 +1,209 @@ +#!/usr/bin/env node + +/** + * create-ojs-app + * CLI tool to scaffold new OpenScript projects + * Similar to create-react-app, create-vue + */ + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { promisify } from "util"; +import { exec } from "child_process"; + +const execAsync = promisify(exec); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ANSI color codes for terminal output +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + green: "\x1b[32m", + blue: "\x1b[34m", + cyan: "\x1b[36m", + yellow: "\x1b[33m", + red: "\x1b[31m", +}; + +function log(message, color = "reset") { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function logSuccess(message) { + log(`✓ ${message}`, "green"); +} + +function logError(message) { + log(`✗ ${message}`, "red"); +} + +function logInfo(message) { + log(message, "cyan"); +} + +async function createProject(projectName, template = "basic") { + const projectPath = path.join(process.cwd(), projectName); + + // Check if directory already exists + if (fs.existsSync(projectPath)) { + logError(`Directory ${projectName} already exists!`); + process.exit(1); + } + + log("\n🚀 Creating new OpenScript project...\n", "bright"); + + // Create project directory + fs.mkdirSync(projectPath, { recursive: true }); + logSuccess(`Created directory: ${projectName}`); + + // Get template path + const templatesDir = path.join(__dirname, "..", "templates"); + const templatePath = path.join(templatesDir, template); + + if (!fs.existsSync(templatePath)) { + logError(`Template "${template}" not found!`); + fs.rmdirSync(projectPath); + process.exit(1); + } + + // Copy template files + logInfo("Copying template files..."); + await copyDirectory(templatePath, projectPath); + logSuccess("Template files copied"); + + // Create package.json + const packageJson = { + name: projectName, + version: "0.1.0", + private: true, + type: "module", + scripts: { + dev: "vite", + build: "vite build", + preview: "vite preview", + }, + dependencies: { + openscriptjs: "^1.0.0", + }, + devDependencies: { + vite: "^5.0.7", + ...(template === "tailwind" + ? { + tailwindcss: "^3.4.0", + postcss: "^8.4.32", + autoprefixer: "^10.4.16", + } + : {}), + }, + }; + + fs.writeFileSync( + path.join(projectPath, "package.json"), + JSON.stringify(packageJson, null, 2) + ); + logSuccess("Created package.json"); + + // Update project name in template files + updateProjectName(projectPath, projectName); + + log("\n📦 Installing dependencies...\n", "bright"); + + try { + // Install dependencies + process.chdir(projectPath); + await execAsync("npm install", { stdio: "inherit" }); + logSuccess("Dependencies installed"); + } catch (error) { + logError("Failed to install dependencies"); + logInfo('You can run "npm install" manually later'); + } + + // Initialize git + try { + await execAsync("git init"); + logSuccess("Initialized git repository"); + } catch (error) { + // Git might not be available, skip silently + } + + // Success message + log("\n" + "=".repeat(50), "green"); + log("🎉 Project created successfully!", "bright"); + log("=".repeat(50) + "\n", "green"); + + logInfo("To get started:"); + log(` cd ${projectName}`, "cyan"); + log(" npm run dev", "cyan"); + + log("\nHappy coding with OpenScript! 🚀\n"); +} + +async function copyDirectory(src, dest) { + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + fs.mkdirSync(destPath, { recursive: true }); + await copyDirectory(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +function updateProjectName(projectPath, projectName) { + // Update index.html title + const indexHtmlPath = path.join(projectPath, "index.html"); + if (fs.existsSync(indexHtmlPath)) { + let content = fs.readFileSync(indexHtmlPath, "utf8"); + content = content.replace( + /.*<\/title>/, + `<title>${projectName}` + ); + fs.writeFileSync(indexHtmlPath, content); + } + + // Update README if exists + const readmePath = path.join(projectPath, "README.md"); + if (fs.existsSync(readmePath)) { + let content = fs.readFileSync(readmePath, "utf8"); + content = content.replace(/{{PROJECT_NAME}}/g, projectName); + fs.writeFileSync(readmePath, content); + } +} + +// Parse command line arguments +const args = process.argv.slice(2); + +if (args.length === 0) { + log("\n❌ Please specify a project name\n", "red"); + log("Usage: npm create ojs-app [template]\n", "cyan"); + log(" or: npx create-ojs-app [template]\n", "cyan"); + log("Available templates:", "cyan"); + log(" basic - Basic OpenScript project (default)", "cyan"); + log(" tailwind - Project with TailwindCSS integration", "cyan"); + log(" bootstrap - Project with Bootstrap 5 integration\n", "cyan"); + process.exit(1); +} + +const projectName = args[0]; +const template = args[1] || "basic"; + +// Validate project name +if (!/^[a-z0-9-_]+$/.test(projectName)) { + logError( + "Project name can only contain lowercase letters, numbers, hyphens, and underscores" + ); + process.exit(1); +} + +createProject(projectName, template).catch((error) => { + logError("Failed to create project"); + console.error(error); + process.exit(1); +}); diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..fec2050 --- /dev/null +++ b/build/README.md @@ -0,0 +1,93 @@ +# OpenScript Build System + +This directory contains the build configuration and tools for bundling OpenScript with Vite. + +## Problem + +When building OpenScript apps with Vite, minification changes class names: +```javascript +class TodoApp extends Component { } // Becomes: class t extends e { } +``` + +This breaks OpenScript's component registration which relies on `constructor.name`: +```javascript +this.name = name ?? this.constructor.name; // Gets 't' instead of 'TodoApp' +``` + +## Solution + +The `vite-plugin-openscript.js` plugin preprocesses component files before bundling with two transformations: + +### 1. Component Name Preservation + +**Before transformation:** +```javascript +class TodoApp extends Component { + render() { + return h.div("Hello"); + } +} +``` + +**After transformation:** +```javascript +class TodoApp extends Component { + constructor(...args) { + super(...args); + this.name = 'TodoApp'; // ← Explicitly set, survives minification + } + render() { + return h.div("Hello"); + } +} +``` + +### 2. Element/Component Name Protection + +**Before transformation:** +```javascript +h.div({ class: "container" }, h.TodoApp()) +``` + +**After transformation:** +```javascript +h['div']({ class: "container" }, h['TodoApp']()) +// ↑ Element and component names preserved as strings +``` + +This prevents minification from mangling property access (e.g., `h.div` → `h.a`). + +## How It Works + +1. **Parse**: Uses Babel to parse component files into an AST +2. **Detect**: Finds all classes that extend `Component` and all `h.property` accesses +3. **Transform**: + - Injects `this.name = 'ComponentName'` in component constructors + - Converts `h.element` to `h['element']` for all property accesses +4. **Generate**: Produces modified code that Vite can bundle safely + +## Build Commands + +```bash +# Install dependencies +npm install + +# Build for production +npm run build + +# Output: dist/openscript.es.js and dist/openscript.umd.js +``` + +## Configuration + +See `vite.config.js` for: +- Plugin registration +- Terser options to preserve class/function names +- Output formats (ES and UMD) +- Source map generation + +## Files + +- **vite-plugin-openscript.js** - Vite plugin for component transformation +- **vite.config.js** - Vite configuration +- **package.json** - Dependencies and scripts diff --git a/build/vite-plugin-openscript.js b/build/vite-plugin-openscript.js new file mode 100644 index 0000000..5862151 --- /dev/null +++ b/build/vite-plugin-openscript.js @@ -0,0 +1,243 @@ +/** + * Vite Plugin: OpenScript Component Name Preserver + * + * This plugin transforms OpenScript component files before bundling to add + * explicit component names that survive minification. + * + * Problem: When Vite minifies code, class names change (e.g., TodoApp -> t), + * breaking OpenScript's component registration which relies on constructor.name + * + * Solution: Parse component files and inject the component name explicitly + * in the constructor, making it immune to minification. + */ + +import { parse } from "@babel/parser"; +import traverse from "@babel/traverse"; +import generate from "@babel/generator"; + +export default function openScriptComponentPlugin() { + return { + name: "vite-plugin-openscript-components", + + // Only transform JS/TS files + transform(code, id) { + // Skip node_modules and non-component files + if (id.includes("node_modules")) { + return null; + } + + // Only process files that likely contain components + if ( + !code.includes("extends Component") && + !code.includes("extend Component") && + !code.includes("h.") && + !code.includes("h[") + ) { + return null; + } + + try { + // Parse the code into an AST + const ast = parse(code, { + sourceType: "module", + plugins: ["jsx", "typescript", "decorators-legacy"], + }); + + let modified = false; + + // Traverse the AST to find component classes + traverse.default(ast, { + ClassDeclaration(path) { + const node = path.node; + + // Check if this class extends Component + if (!node.superClass) return; + + const extendsComponent = + node.superClass.name === "Component" || + node.superClass.property?.name === "Component"; + + if (!extendsComponent) return; + + // Get the component name + const componentName = node.id.name; + + // Check if constructor exists + let hasConstructor = false; + let constructorPath = null; + + for (const member of node.body.body) { + if ( + member.type === "ClassMethod" && + member.kind === "constructor" + ) { + hasConstructor = true; + constructorPath = member; + break; + } + } + + if (hasConstructor && constructorPath) { + // Check if super() exists and add name assignment after it + const body = constructorPath.body.body; + let superIndex = -1; + + for (let i = 0; i < body.length; i++) { + const stmt = body[i]; + if ( + stmt.type === "ExpressionStatement" && + stmt.expression.type === "CallExpression" && + stmt.expression.callee.type === "Super" + ) { + superIndex = i; + break; + } + } + + // Check if name is already set + const hasNameAssignment = body.some( + (stmt) => + stmt.type === "ExpressionStatement" && + stmt.expression.type === + "AssignmentExpression" && + stmt.expression.left.property?.name === + "name" + ); + + if (superIndex !== -1 && !hasNameAssignment) { + // Insert `this.name = 'ComponentName';` after super() + body.splice(superIndex + 1, 0, { + type: "ExpressionStatement", + expression: { + type: "AssignmentExpression", + operator: "=", + left: { + type: "MemberExpression", + object: { type: "ThisExpression" }, + property: { + type: "Identifier", + name: "name", + }, + computed: false, + }, + right: { + type: "StringLiteral", + value: componentName, + }, + }, + }); + modified = true; + } + } else { + // No constructor exists, add one with super() and name assignment + node.body.body.unshift({ + type: "ClassMethod", + kind: "constructor", + key: { + type: "Identifier", + name: "constructor", + }, + params: [ + { + type: "RestElement", + argument: { + type: "Identifier", + name: "args", + }, + }, + ], + body: { + type: "BlockStatement", + body: [ + { + type: "ExpressionStatement", + expression: { + type: "CallExpression", + callee: { type: "Super" }, + arguments: [ + { + type: "SpreadElement", + argument: { + type: "Identifier", + name: "args", + }, + }, + ], + }, + }, + { + type: "ExpressionStatement", + expression: { + type: "AssignmentExpression", + operator: "=", + left: { + type: "MemberExpression", + object: { + type: "ThisExpression", + }, + property: { + type: "Identifier", + name: "name", + }, + computed: false, + }, + right: { + type: "StringLiteral", + value: componentName, + }, + }, + }, + ], + }, + }); + modified = true; + } + }, + + // Transform h.div(...) to h['div'](...) + // This prevents minification from mangling element/component names + MemberExpression(path) { + const node = path.node; + + // Only transform if: + // 1. Object is identifier 'h' + // 2. Property is accessed with dot notation (not already computed) + // 3. Not already a computed member expression + if ( + node.object.type === "Identifier" && + node.object.name === "h" && + !node.computed && + node.property.type === "Identifier" + ) { + // Convert to computed member expression: h['propertyName'] + node.computed = true; + node.property = { + type: "StringLiteral", + value: node.property.name, + }; + modified = true; + } + }, + }); + + // If we modified the AST, generate new code + if (modified) { + const output = generate.default(ast, {}, code); + return { + code: output.code, + map: output.map, + }; + } + + return null; + } catch (error) { + // If parsing fails, log and return original code + console.warn( + `Failed to parse ${id} for OpenScript component transformation:`, + error.message + ); + return null; + } + }, + }; +} diff --git a/docs/TAILWIND_INTEGRATION.md b/docs/TAILWIND_INTEGRATION.md new file mode 100644 index 0000000..949648e --- /dev/null +++ b/docs/TAILWIND_INTEGRATION.md @@ -0,0 +1,183 @@ +# TailwindCSS Integration with OpenScript + +## Overview + +OpenScript supports TailwindCSS through a custom integration that recognizes class names in the OSM (OpenScript Markup) syntax. + +## How It Works + +### 1. Content Detection + +Tailwind's JIT compiler scans JavaScript files for class names. Our configuration targets the OSM pattern: + +```javascript +h.div({ class: "bg-blue-500 text-white p-4" }, "Content") +``` + +The `class` attribute values are automatically detected by Tailwind's content scanner. + +### 2. Dynamic Classes + +For dynamically generated classes, use the `safelist` in `tailwind.config.js`: + +```javascript +// In tailwind.config.js +safelist: [ + { + pattern: /bg-(red|green|blue)-(100|900)/, + } +] +``` + +This ensures Tailwind includes these classes even if they're not found during static analysis. + +## Usage Examples + +### Basic Usage + +```javascript +import { Component, h } from './index.js'; + +class Card extends Component { + render(title, content, ...args) { + return h.div( + { + class: "bg-white rounded-lg shadow-lg p-6 max-w-sm mx-auto" + }, + h.h2({ class: "text-2xl font-bold text-gray-800 mb-4" }, title), + h.p({ class: "text-gray-600" }, content), + ...args + ); + } +} +``` + +### Conditional Classes + +```javascript +class Button extends Component { + render(text, variant = 'primary', ...args) { + const baseClasses = "px-4 py-2 rounded font-medium transition-colors"; + const variantClasses = { + primary: "bg-blue-500 hover:bg-blue-600 text-white", + secondary: "bg-gray-200 hover:bg-gray-300 text-gray-800", + danger: "bg-red-500 hover:bg-red-600 text-white" + }; + + return h.button({ + class: `${baseClasses} ${variantClasses[variant]}` + }, text, ...args); + } +} +``` + +### Responsive Design + +```javascript +class ResponsiveGrid extends Component { + render(...items) { + return h.div( + { + class: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" + }, + ...items + ); + } +} +``` + +### Using State for Dynamic Classes + +```javascript +import { Component, h, state } from './index.js'; + +class TodoItem extends Component { + render(todo, ...args) { + return h.div( + { + class: `p-4 border rounded ${ + todo.completed.value + ? 'bg-green-50 border-green-200' + : 'bg-white border-gray-200' + }` + }, + h.span({ + class: todo.completed.value + ? 'line-through text-gray-500' + : 'text-gray-800' + }, todo.text), + ...args + ); + } +} +``` + +## Custom Utility Classes + +Define reusable component classes in `styles/tailwind.css`: + +```css +@layer components { + .os-card { + @apply bg-white rounded-lg shadow-md p-4; + } + + .os-btn-primary { + @apply px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600; + } +} +``` + +Usage: +```javascript +h.div({ class: "os-card" }, + h.button({ class: "os-btn-primary" }, "Click me") +) +``` + +## Best Practices + +1. **Use Template Literals for Complex Classes**: + ```javascript + class: `base-classes ${condition ? 'variant-a' : 'variant-b'}` + ``` + +2. **Create Helper Functions**: + ```javascript + function classNames(...classes) { + return classes.filter(Boolean).join(' '); + } + + h.div({ class: classNames( + 'base-class', + isActive && 'active-class', + hasError && 'error-class' + )}) + ``` + +3. **Safelist Dynamic Patterns**: + If generating classes from data, add patterns to `safelist` in config + +4. **Use Custom Components**: + Create reusable components with predefined Tailwind styles + +## Setup + +```bash +# Install dependencies +npm install + +# Development with hot reload +npm run dev + +# Build for production (includes Tailwind purge) +npm run build +``` + +## Integration with Vite + +The build process: +1. PostCSS processes Tailwind directives +2. OpenScript plugin transforms components +3. Tailwind JIT scans for class names +4. Vite bundles everything with purged CSS diff --git a/examples/advanced-features.js b/examples/advanced-features.js new file mode 100644 index 0000000..a405a8c --- /dev/null +++ b/examples/advanced-features.js @@ -0,0 +1,77 @@ +import { Component, h, state, putContext, context } from "../index.js"; + +// 1. Fragments Example +class FragmentComponent extends Component { + render(...args) { + // h.$ or h._ creates a document fragment + // This allows returning multiple elements without a parent wrapper + return h.$( + h.h3("Fragment Header"), + h.p("This content is inside a fragment."), + h.p("No extra div wrapper is added to the DOM.") + ); + } +} + +// 2. State Management Example +const counter = state(0); + +class CounterComponent extends Component { + render(...args) { + // Pass the state to the component to auto-subscribe + // The component will re-render when 'counter' changes + return h.div( + h.h3(`Count: ${counter.value}`), + h.button( + { onclick: () => counter.value++ }, + "Increment" + ), + ...args + ); + } +} + +// 3. Context Example +// Define a context (normally this would be in a separate file) +class ThemeContext { + constructor() { + this.theme = state("light"); + } + + toggle() { + this.theme.value = this.theme.value === "light" ? "dark" : "light"; + } +} + +// Register the context +// putContext(referenceName, qualifiedName) +// Since we are not loading from a file here, we just register it manually for this example +// In a real app, you might use: putContext("Theme", "contexts.ThemeContext") +const themeCtx = new ThemeContext(); +context("Theme", themeCtx); // Manually putting it in the provider for this example + +class ThemedComponent extends Component { + constructor() { + super(); + // Access the context + this.themeContext = context("Theme"); + } + + render(...args) { + const currentTheme = this.themeContext.theme.value; + + return h.div( + { + style: `background-color: ${currentTheme === 'light' ? '#fff' : '#333'}; color: ${currentTheme === 'light' ? '#000' : '#fff'}; padding: 20px;` + }, + h.h3(`Current Theme: ${currentTheme}`), + h.button( + { onclick: () => this.themeContext.toggle() }, + "Toggle Theme" + ), + ...args + ); + } +} + +export { FragmentComponent, CounterComponent, ThemedComponent }; diff --git a/examples/basic-app/contexts.js b/examples/basic-app/contexts.js new file mode 100644 index 0000000..05e3413 --- /dev/null +++ b/examples/basic-app/contexts.js @@ -0,0 +1,87 @@ +/** + * Context and State Initialization for Todo App + * Global state management following OpenScript best practices + */ + +import { context, dom, putContext } from "../../index.js"; +import { saveTodosToLocalStorage } from "./helpers.js"; + +// ============================================ +// 1. REGISTER CONTEXTS +// ============================================ + +// Register contexts (creates Context instances) +putContext(["global", "todo", "ui"], "TodoAppContext"); + +// ============================================ +// 2. GET CONTEXT REFERENCES +// ============================================ + +/** + * Global Context - Application-wide state + * @type {Context} + */ +export const gc = context("global"); + +/** + * Todo Context - Todo items and filtering + * @type {Context} + */ +export const tc = context("todo"); + +/** + * UI Context - UI state (modals, loading, etc.) + * @type {Context} + */ +export const uic = context("ui"); + +// ============================================ +// 3. INITIALIZE GLOBAL CONTEXT STATES +// ============================================ + +gc.states({ + appName: "Todo List App", + version: "1.0.0", + isInitialized: false, +}); + +// Set root element for global context +gc.rootElement = dom.id("app-root"); + +// ============================================ +// 4. INITIALIZE TODO CONTEXT STATES +// ============================================ + +tc.states({ + todos: [], // Array of todo items + filter: "all", // "all" | "active" | "completed" + sortBy: "createdAt", // "createdAt" | "text" | "priority" + nextId: 1, +}); + +// ============================================ +// 5. INITIALIZE UI CONTEXT STATES +// ============================================ + +uic.states({ + loading: false, + editingTodoId: null, + showDeleteConfirm: false, + todoToDelete: null, +}); + +// ============================================ +// 6. STATE LISTENERS +// ============================================ + +// Listen to todo changes +tc.todos.listener((todosState) => { + console.log(`Todos updated: ${todosState.value.length} todos`); + // Save to localStorage + saveTodosToLocalStorage(todosState.value); +}); + +// Listen to filter changes +tc.filter.listener((filterState) => { + console.log(`Filter changed to: ${filterState.value}`); +}); \ No newline at end of file diff --git a/examples/basic-app/events.js b/examples/basic-app/events.js new file mode 100644 index 0000000..b5ee630 --- /dev/null +++ b/examples/basic-app/events.js @@ -0,0 +1,67 @@ +/** + * Event Definitions for Basic App + * Centralized event catalog following OpenScript best practices + */ + +import { broker } from "../../index.js"; + +/** + * Application Events + * Structure: Nested object where keys become namespaced event names + * Example: app.started becomes "app:started" + */ +export const $e = { + app: { + started: true, + ready: true, + }, + + todo: { + added: true, + updated: true, + deleted: true, + completed: true, + uncompleted: true, + + needs: { + add: true, + update: true, + delete: true, + toggle: true, + filter: true, + }, + + has: { + addError: true, + updateError: true, + deleteError: true, + list: true, + } + }, + + filter: { + changed: true, + cleared: true, + + needs: { + apply: true, + clear: true, + } + }, + + ui: { + needs: { + modal: true, + confirm: true, + toast: true, + }, + + modal: { + opened: true, + closed: true, + } + } +}; + +// Register all events with the broker +broker.registerEvents($e); diff --git a/examples/basic-app/helpers.js b/examples/basic-app/helpers.js new file mode 100644 index 0000000..c55dc20 --- /dev/null +++ b/examples/basic-app/helpers.js @@ -0,0 +1,71 @@ +/** + * Helper Functions for Todo App + * Utility functions for working with todos and app state + */ + +import { tc, uic } from "./contexts.js"; + +/** + * Get filtered todos based on current filter + * @returns {Array} Filtered todos + */ +export function getFilteredTodos() { + const todos = tc.todos.value; + const filter = tc.filter.value; + + switch (filter) { + case "active": + return todos.filter(t => !t.completed); + case "completed": + return todos.filter(t => t.completed); + default: + return todos; + } +} + +/** + * Get todo statistics + * @returns {Object} Stats object with total, completed, and active counts + */ +export function getTodoStats() { + const todos = tc.todos.value; + return { + total: todos.length, + completed: todos.filter(t => t.completed).length, + active: todos.filter(t => !t.completed).length + }; +} + +/** + * Save todos to localStorage + * @param {Array} todos - Array of todo items + */ +export function saveTodosToLocalStorage(todos) { + try { + localStorage.setItem('openscript-todos', JSON.stringify(todos)); + } catch (e) { + console.error('Failed to save todos:', e); + } +} + +/** + * Load todos from localStorage + * @returns {Array} Loaded todos or empty array + */ +export function loadTodosFromLocalStorage() { + try { + const saved = localStorage.getItem('openscript-todos'); + return saved ? JSON.parse(saved) : []; + } catch (e) { + console.error('Failed to load todos:', e); + return []; + } +} + +/** + * Set loading state + * @param {boolean} isLoading - Loading state + */ +export function setLoading(isLoading) { + uic.loading.value = isLoading; +} diff --git a/examples/basic-app/index.html b/examples/basic-app/index.html new file mode 100644 index 0000000..3fd1de3 --- /dev/null +++ b/examples/basic-app/index.html @@ -0,0 +1,21 @@ + + + + + + + Todo App - OpenScript + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/examples/basic-app/index.js b/examples/basic-app/index.js new file mode 100644 index 0000000..1f0b073 --- /dev/null +++ b/examples/basic-app/index.js @@ -0,0 +1,51 @@ +/** + * Main Entry Point for Todo App + * Initializes the application and starts routing + */ + +// Import event definitions and register them +import { $e } from "./events.js"; + +// Import contexts (this initializes all states and listeners) +import { gc, tc, uic } from "./contexts.js"; + +// Import helper functions +import { loadTodosFromLocalStorage } from "./helpers.js"; + +// Import routes (this registers all routes) +import "./routes.js"; + +// Import OpenScript utilities +import { broker, router } from "../../index.js"; + +// ============================================ +// APPLICATION INITIALIZATION +// ============================================ + +console.log("🚀 Initializing Todo App..."); + +// Load saved todos from localStorage +const savedTodos = loadTodosFromLocalStorage(); +if (savedTodos.length > 0) { + tc.todos.value = savedTodos; + // Update nextId based on loaded todos + const maxId = Math.max(...savedTodos.map(t => t.id || 0)); + tc.nextId = maxId + 1; +} + +// Emit app started event +broker.send($e.app.started); + +// Mark app as initialized +gc.isInitialized.value = true; + +console.log("✓ Todo App initialized successfully"); + +// ============================================ +// START ROUTER +// ============================================ + +// Start listening to route changes +router.listen(); + +console.log("✓ Router started"); diff --git a/examples/basic-app/pages/TodoApp.js b/examples/basic-app/pages/TodoApp.js new file mode 100644 index 0000000..f87c400 --- /dev/null +++ b/examples/basic-app/pages/TodoApp.js @@ -0,0 +1,183 @@ +/** + * TodoApp - Root Layout Component + * Main page layout for the todo application + */ + +import { Component, h } from "../../../index.js"; +import { gc, tc } from "../contexts.js"; + +export default class TodoApp extends Component { + render(...args) { + return h.div( + { class: "container py-5" }, + + // Header + h.div( + { class: "row mb-4" }, + h.div( + { class: "col-12 text-center" }, + h.h1( + { class: "display-4 mb-2" }, + h.i({ class: "fas fa-check-circle text-primary me-3" }), + gc.appName.value + ), + h.p( + { class: "text-muted" }, + "A simple and elegant todo list built with OpenScript" + ) + ) + ), + + // Main content area + h.div( + { class: "row" }, + h.div( + { class: "col-md-8 offset-md-2 col-lg-6 offset-lg-3" }, + + // Card container + h.div( + { class: "card shadow-sm" }, + + // Card body + h.div( + { class: "card-body p-4" }, + + // Todo input form placeholder + h.div( + { class: "mb-4" }, + h.div( + { class: "input-group" }, + h.input({ + type: "text", + class: "form-control form-control-lg", + placeholder: "What needs to be done?", + id: "todo-input" + }), + h.button( + { + class: "btn btn-primary", + type: "button" + }, + h.i({ class: "fas fa-plus me-2" }), + "Add" + ) + ) + ), + + // Filter tabs + h.ul( + { class: "nav nav-pills mb-4" }, + h.li( + { class: "nav-item" }, + h.a( + { + class: "nav-link active", + href: "#" + }, + "All" + ) + ), + h.li( + { class: "nav-item" }, + h.a( + { + class: "nav-link", + href: "#" + }, + "Active" + ) + ), + h.li( + { class: "nav-item" }, + h.a( + { + class: "nav-link", + href: "#" + }, + "Completed" + ) + ) + ), + + // Todo list placeholder + h.div( + { class: "todo-list" }, + tc.todos.value.length === 0 + ? h.div( + { class: "text-center text-muted py-5" }, + h.i({ class: "fas fa-inbox fa-3x mb-3 d-block" }), + h.p("No todos yet. Add one above to get started!") + ) + : h.div( + { class: "list-group" }, + ...tc.todos.value.map(todo => + h.div( + { + class: `list-group-item d-flex align-items-center ${ + todo.completed ? 'bg-light' : '' + }` + }, + h.input({ + type: "checkbox", + class: "form-check-input me-3", + checked: todo.completed + }), + h.span( + { + class: todo.completed + ? 'text-decoration-line-through text-muted flex-grow-1' + : 'flex-grow-1' + }, + todo.text + ), + h.button( + { + class: "btn btn-sm btn-outline-danger", + type: "button" + }, + h.i({ class: "fas fa-trash" }) + ) + ) + ) + ) + ) + ), + + // Card footer with stats + h.div( + { class: "card-footer bg-transparent" }, + h.div( + { class: "d-flex justify-content-between align-items-center" }, + h.small( + { class: "text-muted" }, + `${tc.todos.value.filter(t => !t.completed).length} items left` + ), + h.small( + { class: "text-muted" }, + h.i({ class: "fas fa-info-circle me-1" }), + `Total: ${tc.todos.value.length}` + ) + ) + ) + ) + ) + ), + + // Footer + h.div( + { class: "row mt-5" }, + h.div( + { class: "col-12 text-center" }, + h.p( + { class: "text-muted small" }, + "Built with ", + h.i({ class: "fas fa-heart text-danger" }), + " using OpenScript Framework" + ) + ) + ), + + ...args + ); + } +} diff --git a/examples/basic-app/routes.js b/examples/basic-app/routes.js new file mode 100644 index 0000000..5a6f8f6 --- /dev/null +++ b/examples/basic-app/routes.js @@ -0,0 +1,55 @@ +/** + * Routes for Todo App + * Defines application routing using OpenScript router + */ + +import { router, h, dom } from "../../index.js"; +import { gc } from "./contexts.js"; +import TodoApp from "./pages/TodoApp.js"; + +/** + * Helper to render a component to the root element + * @param {Component} component - Component to render + */ +const app = (component) => { + return component({ + parent: gc.rootElement, + resetParent: true + }); +}; + +// ============================================ +// ROUTE DEFINITIONS +// ============================================ + +// Set base path (empty for this simple app) +router.basePath(""); + +// Default route - redirect to home +router.default(() => router.to("home")); + +// Home route - shows all todos +router.on("/", () => { + console.log("Route: Home"); + app(h.TodoApp()); +}, "home"); + +// Filter routes +router.prefix("filter").group(() => { + router.on("/all", () => { + console.log("Route: Filter - All"); + app(h.TodoApp()); + }, "filter.all"); + + router.on("/active", () => { + console.log("Route: Filter - Active"); + app(h.TodoApp()); + }, "filter.active"); + + router.on("/completed", () => { + console.log("Route: Filter - Completed"); + app(h.TodoApp()); + }, "filter.completed"); +}); + +console.log("✓ Routes registered"); diff --git a/examples/basic-usage.js b/examples/basic-usage.js new file mode 100644 index 0000000..f7239e3 --- /dev/null +++ b/examples/basic-usage.js @@ -0,0 +1,23 @@ +import { Runner, Component, h, State } from "../index.js"; + +// Define a State +const counter = State.state(0); + +// Define a Component +class CounterComponent extends Component { + render(...args) { + return h.div( + h.h1(`Counter: ${counter.value}`), + h.button( + { + onclick: () => counter.value++, + }, + "Increment" + ), + ...args + ); + } +} + +// Mount the Component +new Runner().run(CounterComponent); diff --git a/examples/component-example.js b/examples/component-example.js new file mode 100644 index 0000000..67cf95c --- /dev/null +++ b/examples/component-example.js @@ -0,0 +1,26 @@ +import { Component, h, broker } from "../index.js"; + +class SenderComponent extends Component { + render(...args) { + return h.button( + { + onclick: () => { + broker.emit("message", "Hello from Sender!"); + }, + }, + "Send Message", + ...args + ); + } +} + +class ReceiverComponent extends Component { + constructor() { + super(); + this.message = "Waiting..."; + } + + render(...args) { + return h.div(`Received: ${this.message}`, ...args); + } +} diff --git a/examples/context-state-example.js b/examples/context-state-example.js new file mode 100644 index 0000000..e33eb3a --- /dev/null +++ b/examples/context-state-example.js @@ -0,0 +1,195 @@ +/** + * Global State with Contexts Example + * Demonstrates best practice: defining states in contexts and passing to components + */ + +import { Component, h, context, putContext, state, dom } from "../index.js"; + +// ============================================ +// 1. INITIALIZE CONTEXTS AND STATES +// ============================================ + +// Create contexts +putContext(["global", "page", "user"], "AppContext"); + +const gc = context("global"); +const pc = context("page"); +const uc = context("user"); + +// Initialize states using .states() helper +pc.states({ + pageTitle: "Dashboard", + loading: false, + currentView: "home" +}); + +uc.states({ + username: "Guest", + isAuthenticated: false, + preferences: { theme: "light" } +}); + +gc.states({ + appName: "MyApp", + version: "1.0.0" +}); + +// You can also add non-reactive properties +gc.apiUrl = "https://api.example.com"; + +// ============================================ +// 2. COMPONENTS RECEIVE STATE VIA RENDER +// ============================================ + +class PageHeader extends Component { + // Receive pageTitle state as parameter + render(pageTitle, appName, ...args) { + return h.header( + { class: "page-header" }, + h.h1(pageTitle.value), // Access state via .value + h.p({ class: "app-name" }, appName.value), + ...args + ); + } +} + +class UserGreeting extends Component { + // Receive user state + render(username, ...args) { + return h.div( + { class: "greeting" }, + h.p(`Welcome, ${username.value}!`), + ...args + ); + } +} + +class ThemeToggle extends Component { + toggleTheme() { + const current = uc.preferences.value.theme; + uc.preferences.value = { + ...uc.preferences.value, + theme: current === "light" ? "dark" : "light" + }; + } + + // Receive preferences state + render(preferences, ...args) { + return h.button( + { + class: "btn btn-secondary", + listeners: { click: this.toggleTheme } + }, + `Theme: ${preferences.value.theme}`, + ...args + ); + } +} + +class LoadingIndicator extends Component { + // Receive loading state + render(loading, ...args) { + if (!loading.value) return null; + + return h.div( + { class: "loading" }, + h.span("Loading..."), + ...args + ); + } +} + +// ============================================ +// 3. MAIN DASHBOARD - PASSES STATES DOWN +// ============================================ + +class Dashboard extends Component { + render(...args) { + return h.div( + { class: "dashboard" }, + // Pass global states to header + h.PageHeader(pc.pageTitle, gc.appName), + + // Pass user state to greeting + h.UserGreeting(uc.username), + + // Pass preferences to theme toggle + h.ThemeToggle(uc.preferences), + + // Pass loading state + h.LoadingIndicator(pc.loading), + + h.div( + { class: "content" }, + h.p("Dashboard content goes here") + ), + ...args + ); + } +} + +// ============================================ +// 4. STATE MANAGEMENT UTILITIES +// ============================================ + +// Function to update page +function navigateToPage(pageName) { + pc.loading.value = true; + pc.pageTitle.value = pageName; + + // Simulate async navigation + setTimeout(() => { + pc.loading.value = false; + }, 500); +} + +// Function to login +function login(username) { + uc.username.value = username; + uc.isAuthenticated.value = true; +} + +// ============================================ +// 5. USAGE EXAMPLE +// ============================================ + +function initializeApp() { + // Add state listeners for logging + pc.pageTitle.listener((state) => { + console.log(`Page changed to: ${state.value}`); + }); + + uc.preferences.listener((state) => { + console.log(`Theme changed to: ${state.value.theme}`); + // Could apply theme to document here + document.body.className = `theme-${state.value.theme}`; + }); + + // Render dashboard with special attributes + const dashboard = h.Dashboard({ + parent: document.getElementById("app"), + resetParent: true // Clear existing content + }); + + // Simulate user login after 1 second + setTimeout(() => { + login("John Doe"); + }, 1000); + + // Simulate page navigation after 2 seconds + setTimeout(() => { + navigateToPage("Profile"); + }, 2000); +} + +// Export for use +export { + Dashboard, + PageHeader, + UserGreeting, + ThemeToggle, + LoadingIndicator, + initializeApp, + navigateToPage, + login +}; diff --git a/examples/event-handling.js b/examples/event-handling.js new file mode 100644 index 0000000..6b7e396 --- /dev/null +++ b/examples/event-handling.js @@ -0,0 +1,135 @@ +import { Mediator, Component, h, broker, payload, Utils } from "../index.js"; + +// 1. Declarative Event Listening (Mediator) +// Mediators are perfect for handling business logic and responding to events. +class AuthMediator extends Mediator { + // The '$$' prefix tells the BrokerRegistrar to register these as event listeners. + // Nested objects create namespaced events. + $$user = { + // Listens to 'user:login' + // Listeners receive 'ed' (EventData string) and 'event' (Event Name) + login: (ed, event) => { + const data = Utils.parsePayload(ed); + console.log("User logged in:", data.message); + + // Respond by emitting another event + broker.send("user:authenticated", payload({ user: data.message.username })); + }, + + // Listens to 'user:logout' + logout: (ed, event) => { + console.log("User logged out"); + } + }; + + $$system = { + // Listens to 'system:boot' + boot: (ed, event) => { + console.log("System booted"); + } + }; +} + +// 2. Advanced Declarative Listening +class AdvancedMediator extends Mediator { + $$user = { + // Listen to multiple events separated by underscore + // This will trigger on 'user:login' OR 'user:logout' + login_logout: (ed, event) => { + console.log(`User event triggered: ${event}`); + } + }; +} + +// 3. Component Triggering Events & Listening +class LoginButton extends Component { + // Define a method to handle component events + // The '$_' prefix allows this method to be used as an event listener in the markup + $_onClick(e) { + broker.send("user:login", payload({ username: "Alice" })); + } + + render(...args) { + return h.button( + { + // Use the defined method as a listener + onclick: this.$_onClick + }, + "Login", + ...args + ); + } +} + +// 4. Listening to Component Events +class UserDashboard extends Component { + render(...args) { + return h.div( + h.h3("Dashboard"), + // Listen to the 'rendered' event of the LoginButton component + // Syntax: h.on(ComponentClass, eventName, callback) + h.on(LoginButton, "rendered", () => { + console.log("Login Button has been rendered!"); + }), + h.component(new LoginButton()), + ...args + ); + } +} + +// 5. State Management in Components +import { state } from "../index.js"; + +class Counter extends Component { + // Create state inside the component + count = state(0); + + $_increment() { + this.count.value++; + } + + // Components automatically listen to state changes when state is passed to render + render(...args) { + return h.div( + h.p(`Count: ${this.count.value}`), + h.button({ onclick: this.$_increment }, "Increment"), + ...args + ); + } +} + +// 6. Direct State Listeners +class StateExample extends Component { + count = state(0); + + constructor() { + super(); + + // Direct listener using state.listener() method + this.count.listener((currentState) => { + console.log(`State changed to: ${currentState.value}`); + }); + } + + render(...args) { + return h.div( + h.p(`Count: ${this.count.value}`), + h.button( + { + onclick: () => this.count.value++ + }, + "Increment" + ), + ...args + ); + } +} + +// 7. Imperative Event Listening +// You can also listen to events directly using the broker instance. +broker.on("user:authenticated", (ed, event) => { + const data = Utils.parsePayload(ed); + console.log("Imperative listener caught authenticated event:", data.message); +}); + +export { AuthMediator, AdvancedMediator, LoginButton, UserDashboard, Counter, StateExample }; diff --git a/examples/full-application.js b/examples/full-application.js new file mode 100644 index 0000000..e6d7119 --- /dev/null +++ b/examples/full-application.js @@ -0,0 +1,334 @@ +/** + * Comprehensive Real-World Example + * This example demonstrates a complete OpenScript application setup, + * mirroring patterns used in the Carata codebase. + */ + +import { + Component, + Mediator, + h, + state, + broker, + router, + context, + putContext, + payload, + Utils +} from "../index.js"; + +// ============================================ +// 1. EVENT REGISTRATION +// ============================================ +// Define all application events in a structured object +const $e = { + system: { + booted: true, + needs: { + reload: true, + } + }, + user: { + authenticated: true, + loggedOut: true, + needs: { + login: true, + logout: true, + profile: true, + }, + has: { + loginError: true, + } + }, + cart: { + itemAdded: true, + needs: { + addition: true, + removal: true, + allItems: true, + }, + has: { + items: true, + } + } +}; + +// Register all events with the broker +broker.registerEvents($e); + +// ============================================ +// 2. CONTEXT INITIALIZATION +// ============================================ +// Create application contexts +putContext(["global", "user", "page"], "AppContext"); + +const gc = context("global"); // Global context +const uc = context("user"); // User context +const pc = context("page"); // Page context + +// Initialize states in contexts +gc.states({ + auth: false, + appName: "MyApp", +}); + +uc.states({ + cart: {}, + profile: null, + isLoggedIn: false, +}); + +pc.states({ + currentPage: "Home", + loading: false, +}); + +// Add state listeners +uc.cart.listener((cartState) => { + console.log(`Cart updated: ${Object.keys(cartState.value).length} items`); +}); + +// ============================================ +// 3. MEDIATOR - BUSINESS LOGIC +// ============================================ +class CartMediator extends Mediator { + // Listen to multiple events with underscore separation + $$cart = { + needs: { + // Single event listener + addition: (ed, event) => { + this.addToCart(ed, event); + }, + + removal: (ed, event) => { + this.removeFromCart(ed, event); + }, + + allItems: () => { + this.fetchCart(); + } + } + }; + + // Multi-event listener - triggers on user:authenticated OR user:loggedOut + $$user = { + authenticated_loggedOut: (ed, event) => { + console.log(`User status changed: ${event}`); + this.broadcast($e.cart.needs.allItems); + } + }; + + async addToCart(ed, event) { + const data = Utils.parsePayload(ed); + const product = data.message.product; + + // Simulate API call + console.log(`Adding ${product.name} to cart`); + + // Update cart in context + const currentCart = {...uc.cart.value}; + currentCart[product.id] = product; + uc.cart.value = currentCart; + + // Broadcast success + this.send($e.cart.itemAdded, payload({ product })); + } + + async removeFromCart(ed, event) { + const data = Utils.parsePayload(ed); + const cartMap = uc.cart.value; + delete cartMap[data.message.productId]; + uc.cart.value = {...cartMap}; + } + + async fetchCart() { + // Simulate fetching cart from API + console.log("Fetching cart items..."); + } +} + +// ============================================ +// 4. COMPONENT - COUNT BUTTON +// ============================================ +class CounterButton extends Component { + count = state(0); + + // Component method with $_ prefix + $_increment() { + this.count.value++; + } + + render(...args) { + return h.div( + { class: "counter-section" }, + h.p(`Count: ${this.count.value}`), + h.button( + { + class: "btn btn-primary", + onclick: this.$_increment + }, + "Increment" + ), + ...args + ); + } +} + +// ============================================ +// 5. COMPONENT - PRODUCT CARD +// ============================================ +class ProductCard extends Component { + // Using h.func to create inline event handlers + render(product, ...args) { + return h.div( + { class: "card" }, + h.div( + { class: "card-body" }, + h.h5({ class: "card-title" }, product.name), + h.p({ class: "card-text" }, `$${product.price}`), + h.button( + { + class: "btn btn-success", + // h.func creates a callable string for inline handlers + onclick: h.func( + "broker.send", + $e.cart.needs.addition, + payload({ product }) + ) + }, + "Add to Cart" + ) + ), + ...args + ); + } +} + +// ============================================ +// 6. COMPONENT - SHOPPING CART +// ============================================ +class ShoppingCart extends Component { + render(...args) { + return h.div( + { class: "cart-container" }, + h.h3("Shopping Cart"), + // Using v() for reactive rendering based on state + h.div( + h.p("Items in cart:"), + h.ul( + // Reactive rendering - automatically updates when uc.cart changes + ...Object.values(uc.cart.value).map(product => + h.li( + product.name, + " - ", + h.button( + { + class: "btn btn-sm btn-danger", + onclick: h.func( + "broker.send", + $e.cart.needs.removal, + payload({ productId: product.id }) + ) + }, + "Remove" + ) + ) + ) + ) + ), + ...args + ); + } +} + +// ============================================ +// 7. COMPONENT - DASHBOARD (Parent Component) +// ============================================ +class Dashboard extends Component { + render(...args) { + const sampleProducts = [ + { id: 1, name: "Widget", price: 9.99 }, + { id: 2, name: "Gadget", price: 19.99 }, + { id: 3, name: "Doohickey", price: 14.99 } + ]; + + return h.div( + { class: "container mt-4" }, + h.div( + { class: "row" }, + h.div( + { class: "col-md-8" }, + h.h2("Products"), + h.div( + { class: "row" }, + ...sampleProducts.map(product => + h.div( + { class: "col-md-4 mb-3" }, + h.ProductCard(product) + ) + ) + ) + ), + h.div( + { class: "col-md-4" }, + // Render counter button + h.CounterButton(), + h.hr(), + // Render shopping cart + h.ShoppingCart(), + // Listen to ProductCard's 'rendered' event + h.on(ProductCard, "rendered", () => { + console.log("Product card rendered"); + }) + ) + ), + ...args + ); + } +} + +// ============================================ +// 8. INITIALIZATION +// ============================================ +function initializeApp() { + // Initialize mediator + const cartMediator = new CartMediator(); + + // Mount the dashboard component to DOM + const root = document.getElementById("app"); + if (root) { + const dashboard = new Dashboard(); + dashboard.mount(root); + + // Broadcast system booted event + broker.broadcast($e.system.booted); + } +} + +// ============================================ +// 9. ROUTE SETUP +// ============================================ +// Routes use a fluent API with .on(), .prefix(), and .group() +router.on("/", () => { + pc.currentPage.value = "Home"; +}, "home"); + +router.prefix("products").group(() => { + router.on("/{productId}/view", () => { + pc.currentPage.value = "Product Details"; + // Access params via router.params.productId + }, "product.view"); +}); + +// Start listening to route changes +router.listen(); + +// Export for use +export { + Dashboard, + ProductCard, + ShoppingCart, + CounterButton, + CartMediator, + initializeApp +}; diff --git a/examples/state-example.js b/examples/state-example.js new file mode 100644 index 0000000..0d68bba --- /dev/null +++ b/examples/state-example.js @@ -0,0 +1,371 @@ +/** + * State Management Example + * Demonstrates various state patterns in OpenScript + */ + +import { Component, h, state } from "../index.js"; + +// ============================================ +// 1. Basic Counter Component with State +// ============================================ +class Counter extends Component { + // Create state directly in the component + count = state(0); + + // Regular component methods (NOT event listeners) + increment() { + this.count.value++; + } + + decrement() { + this.count.value--; + } + + reset() { + this.count.value = 0; + } + + // Component automatically re-renders when state changes + render(...args) { + return h.div( + { class: "counter-container" }, + h.h3("Counter Example"), + h.p( + { class: "count-display" }, + "Count: ", + h.strong(this.count.value) + ), + h.div( + { class: "button-group" }, + // Using listeners attribute + h.button( + { + class: "btn btn-success", + listeners: { click: this.increment } + }, + "+" + ), + h.button( + { + class: "btn btn-danger", + listeners: { click: this.decrement } + }, + "-" + ), + // Alternative: using this.method() + h.button( + { + class: "btn btn-secondary", + onclick: this.method("reset") + }, + "Reset" + ) + ), + ...args + ); + } +} + +// ============================================ +// 2. Todo List Component with Array State +// ============================================ +class TodoList extends Component { + todos = state([]); + inputValue = state(""); + + addTodo() { + if (this.inputValue.value.trim()) { + // Push new todo to the array + this.todos.value = [ + ...this.todos.value, + { + id: Date.now(), + text: this.inputValue.value, + completed: false + } + ]; + this.inputValue.value = ""; + } + } + + toggleTodo(id) { + this.todos.value = this.todos.value.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo + ); + } + + deleteTodo(id) { + this.todos.value = this.todos.value.filter(todo => todo.id !== id); + } + + updateInput(e) { + this.inputValue.value = e.target.value; + } + + render(...args) { + return h.div( + { class: "todo-container" }, + h.h3("Todo List Example"), + + // Input form + h.div( + { class: "input-group mb-3" }, + h.input({ + type: "text", + class: "form-control", + placeholder: "Enter a todo...", + value: this.inputValue.value, + listeners: { + input: this.updateInput, + keypress: (e) => { + if (e.key === "Enter") this.addTodo(); + } + } + }), + h.button( + { + class: "btn btn-primary", + listeners: { click: this.addTodo } + }, + "Add" + ) + ), + + // Todo list + h.ul( + { class: "list-group" }, + ...this.todos.value.map(todo => + h.li( + { + class: "list-group-item d-flex justify-content-between align-items-center", + style: todo.completed ? "text-decoration: line-through; opacity: 0.6" : "" + }, + h.span( + { + onclick: () => this.toggleTodo(todo.id), + style: "cursor: pointer; flex: 1" + }, + todo.text + ), + h.button( + { + class: "btn btn-sm btn-danger", + onclick: () => this.deleteTodo(todo.id) + }, + "Delete" + ) + ) + ) + ), + + // Stats + h.p( + { class: "mt-3" }, + `Total: ${this.todos.value.length} | `, + `Completed: ${this.todos.value.filter(t => t.completed).length}` + ), + ...args + ); + } +} + +// ============================================ +// 3. Form Component with Object State +// ============================================ +class UserForm extends Component { + formData = state({ + name: "", + email: "", + age: "" + }); + + submitted = state(false); + + updateField(field, value) { + this.formData.value = { + ...this.formData.value, + [field]: value + }; + } + + handleSubmit(e) { + e.preventDefault(); + console.log("Form submitted:", this.formData.value); + this.submitted.value = true; + + // Reset after 2 seconds + setTimeout(() => { + this.submitted.value = false; + }, 2000); + } + + render(...args) { + return h.div( + { class: "form-container" }, + h.h3("User Form Example"), + + h.form( + { listeners: { submit: this.handleSubmit } }, + h.div( + { class: "mb-3" }, + h.label({ class: "form-label" }, "Name"), + h.input({ + type: "text", + class: "form-control", + value: this.formData.value.name, + listeners: { + input: (e) => this.updateField("name", e.target.value) + } + }) + ), + h.div( + { class: "mb-3" }, + h.label({ class: "form-label" }, "Email"), + h.input({ + type: "email", + class: "form-control", + value: this.formData.value.email, + listeners: { + input: (e) => this.updateField("email", e.target.value) + } + }) + ), + h.div( + { class: "mb-3" }, + h.label({ class: "form-label" }, "Age"), + h.input({ + type: "number", + class: "form-control", + value: this.formData.value.age, + listeners: { + input: (e) => this.updateField("age", e.target.value) + } + }) + ), + h.button( + { type: "submit", class: "btn btn-primary" }, + "Submit" + ), + this.submitted.value + ? h.div( + { class: "alert alert-success mt-3" }, + "Form submitted successfully!" + ) + : null + ), + ...args + ); + } +} + +// ============================================ +// 4. State with Listeners +// ============================================ +class StateListenerExample extends Component { + temperature = state(20); + + constructor() { + super(); + + // Add a listener that fires whenever temperature changes + this.temperature.listener((tempState) => { + console.log(`Temperature changed to: ${tempState.value}°C`); + + // You could trigger side effects here + if (tempState.value > 30) { + console.warn("Temperature is getting high!"); + } + }); + } + + increase() { + this.temperature.value += 5; + } + + decrease() { + this.temperature.value -= 5; + } + + render(...args) { + const temp = this.temperature.value; + let status = "Normal"; + let statusClass = "badge bg-success"; + + if (temp > 30) { + status = "Hot"; + statusClass = "badge bg-danger"; + } else if (temp < 10) { + status = "Cold"; + statusClass = "badge bg-primary"; + } + + return h.div( + { class: "temperature-container" }, + h.h3("State Listener Example"), + h.p("Check console for state change logs"), + h.div( + { class: "display-4" }, + `${temp}°C `, + h.span({ class: statusClass }, status) + ), + h.div( + { class: "button-group mt-3" }, + h.button( + { + class: "btn btn-primary", + listeners: { click: this.increase } + }, + "Increase" + ), + h.button( + { + class: "btn btn-info", + listeners: { click: this.decrease } + }, + "Decrease" + ) + ), + ...args + ); + } +} + +// ============================================ +// 5. Demo Page - All Examples Together +// ============================================ +class StateDemo extends Component { + render(...args) { + return h.div( + { class: "container mt-4" }, + h.h1("OpenScript State Management Examples"), + h.hr(), + + h.div( + { class: "row" }, + h.div( + { class: "col-md-6 mb-4" }, + h.Counter() + ), + h.div( + { class: "col-md-6 mb-4" }, + h.StateListenerExample() + ) + ), + + h.div( + { class: "row" }, + h.div( + { class: "col-md-6 mb-4" }, + h.TodoList() + ), + h.div( + { class: "col-md-6 mb-4" }, + h.UserForm() + ) + ), + ...args + ); + } +} + +export { Counter, TodoList, UserForm, StateListenerExample, StateDemo }; diff --git a/index.js b/index.js deleted file mode 100644 index a072b61..0000000 --- a/index.js +++ /dev/null @@ -1,83 +0,0 @@ -import { - dom, - Filter, - FSEventEmitter, - IdGenerator, - Pipeline, - Requester, - tool, -} from "./src/fotastart"; -import { fireEvent, redirect, sendEvent } from "./src/helpers"; -import { - autoload, - broker, - component, - context, - ContextProvider, - contextProvider, - each, - eData, - fetchContext, - h, - include, - lazyFor, - loader, - MediatorManager, - mediatorManager, - mediators, - namespace, - OJS, - ojs, - OpenScript, - payload, - putContext, - req, - route, - Router, - state, - Utils, - v, -} from "./src/open-script"; - -tool.addToWindow(fireEvent, sendEvent, redirect, IdGenerator); - -tool.makeGlobal( - tool, - dom, - IdGenerator, - Requester, - Pipeline, - Filter, - FSEventEmitter -); - -tool.addToWindow( - route, - OJS, - OpenScript, - Router, - broker, - h, - contextProvider, - ContextProvider, - mediatorManager, - MediatorManager, - ojs, - req, - include, - namespace, - state, - context, - mediators, - each, - lazyFor, - v, - payload, - eData, - loader, - putContext, - fetchContext, - autoload, - Utils, - component -); diff --git a/ojs-config.json b/ojs-config.json deleted file mode 100644 index 9b93002..0000000 --- a/ojs-config.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "outputFile": { - "name" : "resources/js/consolidated-ojs.js" - }, - - "dir": { - "components": "public/js/ui/components", - "mediators": "public/js/mediators" - }, - - "content": { - "prefixFiles": { - "public/js/": [ - "vanilla-otp.min.js", - "open-script.js", - "ojs-config.js", - "ojs-events.js", - "fotastart.js", - "helpers.js", - "analytics.js", - "declarations.js", - "admin-declarations.js", - "global-config.js", - "index.js", - "routes.js", - "admin-routes.js" - ] - }, - "suffixFiles": { - "public/js/": ["activate-bs-tooltips.js", "boot.js"] - } - } -} diff --git a/optimizations.md b/optimizations.md new file mode 100644 index 0000000..6ccd30a --- /dev/null +++ b/optimizations.md @@ -0,0 +1,25 @@ +# Optimizations for OpenScript Framework + +## 1. Dependency Injection +Currently, dependencies like `broker` and `h` are often treated as globals or singletons. Implementing a proper Dependency Injection (DI) container would make the code more testable and modular. + +## 2. Virtual DOM Improvements +The `DOMReconciler` performs a basic diff. Adopting a more robust virtual DOM algorithm (like Snabbdom or Preact's reconciler) could improve performance for large updates. + +## 3. Tree Shaking +Ensure that the build process (e.g., Webpack or Rollup) can effectively tree-shake unused modules. The modular structure is a good start, but verify that side-effects are minimized. + +## 4. TypeScript +Migrating to TypeScript would add type safety, improve developer experience with autocompletion, and catch errors at compile time. + +## 5. Unit Testing +Add a comprehensive unit test suite using Jest or Vitest. The modular structure makes it easier to test individual components and classes in isolation. + +## 6. State Management +Consider a more centralized state management solution (like Redux or MobX patterns) if the application grows complex, although the current `State` class provides a good reactive primitive. + +## 7. Web Components +Evaluate wrapping OpenScript components as standard Web Components (Custom Elements) for better interoperability with other frameworks. + +## 8. CSS-in-JS +Integrate a CSS-in-JS solution or scoped CSS to handle component styling more effectively than inline styles or global CSS. diff --git a/package.json b/package.json index 978b522..9523683 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,81 @@ { - "name": "modular-openscript", + "name": "openscriptjs", "version": "1.0.0", - "description": "Lightweight JavaScript Frontend Framework for Build properly engineered frontend", - "keywords": [ - "OpenScript.js", - "OpenScriptJs" + "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", + "type": "module", + "main": "./dist/openscript.umd.js", + "module": "./dist/openscript.es.js", + "exports": { + ".": { + "import": "./dist/openscript.es.js", + "require": "./dist/openscript.umd.js" + }, + "./styles": "./dist/styles/tailwind.css", + "./plugin": "./build/vite-plugin-openscript.js" + }, + "bin": { + "create-ojs-app": "./bin/create-ojs-app.js" + }, + "files": [ + "dist", + "bin", + "templates", + "build/vite-plugin-openscript.js", + "styles", + "README.md", + "LICENSE" ], - "homepage": "https://github.com/OpenScriptJs/modular-openscript#readme", - "bugs": { - "url": "https://github.com/OpenScriptJs/modular-openscript/issues" + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "prepublishOnly": "npm run build", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, + "keywords": [ + "openscript", + "openscriptjs", + "framework", + "reactive", + "components", + "spa", + "web-framework", + "javascript", + "frontend", + "ui" + ], + "author": "Levi Kamara Zwannah", + "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/OpenScriptJs/modular-openscript.git" + "url": "https://github.com/yourusername/openscriptjs.git" }, - "license": "ISC", - "author": "Levi Kamara Zwannah ", - "type": "commonjs", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "bugs": { + "url": "https://github.com/yourusername/openscriptjs/issues" + }, + "homepage": "https://github.com/yourusername/openscriptjs#readme", + "engines": { + "node": ">=16.0.0" + }, + "devDependencies": { + "@babel/core": "^7.23.5", + "@babel/generator": "^7.23.5", + "@babel/parser": "^7.23.5", + "@babel/traverse": "^7.23.5", + "@testing-library/dom": "^10.4.1", + "@vitest/ui": "^4.0.13", + "autoprefixer": "^10.4.16", + "happy-dom": "^20.0.10", + "jsdom": "^27.2.0", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "vite": "^5.0.7", + "vitest": "^4.0.13" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/broker/Broker.js b/src/broker/Broker.js new file mode 100644 index 0000000..7cdfe8d --- /dev/null +++ b/src/broker/Broker.js @@ -0,0 +1,275 @@ +import Emitter from "../core/Emitter.js"; + +/** + * The Broker Class + */ +export default class Broker { + /** + * Should the events be logged as they are fired? + */ + #shouldLog = false; + + #emitOnlyRegisteredEvents = false; + + /** + * The event listeners + * event: {time:xxx, args: xxx} + */ + #logs = {}; + + /** + * The emitter + */ + #emitter = new Emitter(); + + constructor() { + /** + * TIME DIFFERENCE BEFORE GARBAGE + * COLLECTION + */ + this.CLEAR_LOGS_AFTER = 10000; + + /** + * TIME TO GARBAGE COLLECTION + */ + this.TIME_TO_GC = 30000; + } + + /** + * Add Event Listeners + * @param {string|Array} events - space or | separated events + * @param {function} listener - asynchronous function + */ + on(events, listener) { + if (Array.isArray(events)) { + for (let event of events) { + this.on(event, listener); + } + + return; + } + + events = this.parseEvents(events); + + for (let event of events) { + event = event.trim(); + + this.verifyEventRegistration(event); + + if (this.#logs[event]) { + let emitted = this.#logs[event]; + + for (let i = 0; i < emitted.length; i++) { + listener(...emitted[i].args); + } + } + + this.#emitter.on(event, listener); + } + } + + verifyEventRegistration(event) { + if ( + this.#emitOnlyRegisteredEvents && + !(event in this.#emitter.listeners) + ) { + throw Error( + `BrokerError: Cannot listen to or emit unregistered event: ${event}. + You can turn off event registration requirement to stop this behavior.` + ); + } + } + + /** + * + * @param {object} events ```json + * { + * event1: true, + * ns: { + * event1: true, + * subNs: { + * event:true + * } + * } + * } + * ``` + * @returns + */ + registerEvents(events) { + const dfs = (event, prefix = "", ref = {}) => { + if (typeof event === "string") { + if (event.length === 0) return; + + let name = event; + + if (prefix.length > 0) { + event = `${prefix}:${event}`; + } + + if (!(event in this.#emitter.listeners)) { + this.#emitter.listeners[event] = []; + + ref[name] = event; + } else { + throw Error( + `Cannot re-register event: ${event}. Event already registered` + ); + } + + return; + } + + const accepted = { + object: true, + boolean: true, + }; + + for (let e in event) { + if (!(typeof event[e] in accepted)) { + throw Error( + `Invalid Event declaration: ${ + prefix ? prefix + "." : "" + }${e}: ${event[e]}` + ); + } + + if (typeof event[e] === "object") { + dfs( + event[e], + `${prefix.length > 0 ? prefix + ":" : prefix}${e}`, + event[e] + ); + } else { + dfs(e, prefix, event); + } + } + + return; + }; + + dfs(events); + } + + /** + * Emits an event + * @param {string|Array} events - space or | separated events + * @param {...any} args + * @returns + */ + async send(events, ...args) { + return this.emit(events, ...args); + } + + /** + * Broadcasts an event + * @param {string|Array} events- space or | separated events + * @param {...any} args + * @returns + */ + async broadcast(events, ...args) { + return this.send(events, ...args); + } + + /** + * Emits Events + * @param {string|Array} events + * @param {...any} args + * @returns + */ + async emit(events, ...args) { + if (Array.isArray(events)) { + for (let event of events) { + this.emit(event, ...args); + } + + return; + } + + events = this.parseEvents(events); + + for (let event of events) { + event = event.trim(); + + this.verifyEventRegistration(event); + + await this.#emit(event, ...args); + } + } + + /** + * @param {string} events + */ + parseEvents(events) { + if (Array.isArray(events)) return events; + return events.split(/\|/g); + } + + async #emit(event, ...args) { + const currentTime = () => new Date().getTime(); + + this.#logs[event] = this.#logs[event] ?? []; + this.#logs[event].push({ timestamp: currentTime(), args: args }); + + // Import EventData dynamically or assume it's available? + // In original code: args.push(new OpenScript.EventData().encode()); + // I'll assume EventData is imported or I'll just skip this for now. + // Wait, I should import EventData. + + // if (args.length == 0) { + // args.push(new OpenScript.EventData().encode()); + // } + + args.push(event); + + if (this.#shouldLog) { + console.trace(`fired ${event}: args: `, args); + } + + return this.#emitter.emit(event, ...args); + } + + /** + * Clear the logs + */ + clearLogs() { + let now = new Date().getTime(); + + for (let event in this.#logs) { + let logs = this.#logs[event]; + let newLogs = []; + + for (let log of logs) { + if (now - log.timestamp < this.TIME_TO_GC) { + newLogs.push(log); + } + } + + this.#logs[event] = newLogs; + } + } + + /** + * Do Events Garbage Collection + */ + removeStaleEvents() { + setInterval(() => { + this.clearLogs(); + }, this.CLEAR_LOGS_AFTER); + } + + /** + * If the broker should display events as they are fired + * @param {boolean} shouldLog + */ + withLogs(shouldLog) { + this.#shouldLog = shouldLog; + } + + /** + * + * @param {boolean} requireEventsRegistration + */ + requireEventsRegistration(requireEventsRegistration = true) { + this.#emitOnlyRegisteredEvents = requireEventsRegistration; + } +} diff --git a/src/broker/BrokerRegistrar.js b/src/broker/BrokerRegistrar.js new file mode 100644 index 0000000..dd48873 --- /dev/null +++ b/src/broker/BrokerRegistrar.js @@ -0,0 +1,79 @@ +import { broker } from "../index.js"; + +/** + * Registers events on the broker + */ +export default class BrokerRegistrar { + async registerNamespace(namespace, events, obj) { + if (typeof events !== "object") { + console.error( + `Namespace has incorrect declaration syntax: '${namespace}' with value: `, + events, + `in ${obj.constructor.name}` + ); + + return; + } + + for (let event in events) { + if ( + event.startsWith("$$") || + (typeof events[event] === "object" && + !(typeof events[event] === "function")) + ) { + this.registerNamespace( + `${namespace}:${ + event.startsWith("$$") ? event.substring(2) : event + }`, + events[event], + obj + ); + } else { + let ev = event.split(/_/g).filter((a) => a.length > 0); + + for (let e of ev) { + this.registerMethod( + `${namespace}:${e}`, + events[event], + obj + ); + } + } + } + } + + async register(o) { + let obj = o; + let seen = new Set(); + + do { + for (let method of Object.getOwnPropertyNames(obj)) { + if (seen.has(method)) continue; + if (method.length < 3) continue; + if (!method.startsWith("$$")) continue; + + if (typeof obj[method] !== "function") { + await this.registerNamespace( + method.substring(2), + obj[method], + obj + ); + continue; + } + + this.registerMethod(method.substring(2), obj[method], obj); + + seen.add(method); + } + } while ((obj = Object.getPrototypeOf(obj))); + } + + async registerMethod(method, listener, object) { + let events = method.split(/_/g).filter((a) => a.length > 0); + + for (let ev of events) { + if (ev.length === 0) continue; + broker.on(ev, listener.bind(object)); + } + } +} diff --git a/src/broker/Listener.js b/src/broker/Listener.js new file mode 100644 index 0000000..867dfae --- /dev/null +++ b/src/broker/Listener.js @@ -0,0 +1,14 @@ +import BrokerRegistrar from "./BrokerRegistrar.js"; + +/** + * A Broker Listener + */ +export default class Listener { + /** + * Registers with the broker + */ + async register() { + let br = new BrokerRegistrar(); + br.register(this); + } +} diff --git a/src/component/Component.js b/src/component/Component.js new file mode 100644 index 0000000..47e85e8 --- /dev/null +++ b/src/component/Component.js @@ -0,0 +1,864 @@ +import Emitter from "../core/Emitter.js"; +import DOMReconciler from "./DOMReconciler.js"; +import BrokerRegistrar from "../broker/BrokerRegistrar.js"; +import { h } from "./h.js"; +import { component } from "../index.js"; +import State from "../core/State.js"; + +/** + * Base Component Class + */ +export default class Component { + /** + * Anonymous component ID + */ + static aCId = 0; + + /** + * Generate IDs for the components + */ + static uid = 0; + + /** + * Use for returning fragments + */ + static FRAGMENT = "OJS-SPECIAL-FRAGMENT"; + + constructor(name = null) { + /** + * List of events that the component emits + */ + this.EVENTS = { + rendered: "rendered", // component is visible on the dom + rerendered: "rerendered", // component was rerendered + premount: "premount", // component is ready to register + mounted: "mounted", // the component is now registered + prebind: "prebind", // the component is ready to bind + bound: "bound", // the component has bound + markupBound: "markup-bound", // a temporary markup has bound + beforeHidden: "before-hidden", + hidden: "hidden", + unmounted: "unmounted", // removed from the markup engine memory + beforeVisible: "before-visible", // before the markup is made visible + visible: "visible", // the markup is now made visible + }; + + /** + * List of all components that are listening to + * specific events + */ + this.listening = {}; + + /** + * All the states that this component is listening to + * @type {object} + */ + this.states = {}; + + /** + * List of components that this component is listening + * to. + */ + this.listeningTo = {}; + + /** + * Has the component being mounted + */ + this.mounted = false; + + /** + * Has the component bound + */ + this.bound = false; + + /** + * Has the component rendered + */ + this.rendered = false; + + /** + * Has the component rerendered + */ + this.rerendered = false; + + /** + * Is the component visible + */ + this.visible = true; + + /** + * The argument Map for rerendering on state changes + */ + this.argsMap = new Map(); + + /** + * Event Emitter for the component + */ + this.emitter = new Emitter(); + + this.isAnonymous = false; + + this.name = name ?? this.constructor.name; + + this.emitter.once( + this.EVENTS.rendered, + (th) => (th.rendered = true) + ); + this.on(this.EVENTS.hidden, (th) => (th.visible = false)); + this.on(this.EVENTS.rerendered, (th) => (th.rerendered = true)); + this.on(this.EVENTS.bound, (th) => (th.bound = true)); + this.on(this.EVENTS.mounted, (th) => (th.mounted = true)); + this.on(this.EVENTS.visible, (th) => (th.visible = true)); + this.getDeclaredListeners(); + + this.$$ojs = { + routeChanged: () => { + setTimeout(() => { + if (this.markup().length == 0) { + if (this.isAnonymous) { + return h.deleteComponent(this.name); + } + + this.releaseMemory(); + } + }, 1000); + }, + }; + + /** + * Compare two Nodes + */ + this.Reconciler = DOMReconciler; + } + + /** + * Write Clean Up Logic in this function + */ + cleanUp() {} + + /** + * Make the component's method accessible from the + * global window + * @param {string} methodName - the method name + * @param {[*]} args - arguments to pass to the method + * To pass a literal string param use '${param}' in the args. + * For example ['${this}'] this will reference the DOM element. + */ + method(name, args) { + if (!Array.isArray(args)) { + args = [args]; + } + return h.func([this, name], ...args); + } + + /** + * Get an external Component's method + * to add it to a DOM Element + * @param {string} componentMethod `Component.method` e.g. 'MainNav.notify' + * @param {[*]} args + */ + xMethod(componentMethod, args) { + let splitted = componentMethod + .trim() + .split(/\./) + .map((a) => a.trim()); + + if (splitted.length < 2) { + console.error( + `${componentMethod} has syntax error. Please use ComponentName.methodName` + ); + } + + return component(splitted[0]).method(splitted[1], args); + } + + /** + * Adds a Listening component + * @param {event} event + * @param {Component} component + */ + addListeningComponent(component, event) { + if (this.emitsTo(component, event)) return; + + if (!this.listening[event]) this.listening[event] = new Map(); + this.listening[event].set(component.name, component); + + component.addEmittingComponent(this, event); + } + + /** + * Adds a component that this component is listening to + * @param {string} event + * @param {Component} component + */ + addEmittingComponent(component, event) { + if (this.listensTo(component, event)) return; + + if (!this.listeningTo[component.name]) + this.listeningTo[component.name] = new Map(); + + this.listeningTo[component.name].set(event, component); + + component.addListeningComponent(this, event); + } + + /** + * Checks if this component is listening + * @param {string} event + * @param {Component} component + */ + emitsTo(component, event) { + return this.listening[event]?.has(component.name) ?? false; + } + + /** + * Checks if this component is listening to the other + * component + * @param {*} event + * @param {*} component + */ + listensTo(component, event) { + return this.listeningTo[component.name]?.has(event) ?? false; + } + + /** + * Deletes a component from the listening array + * @param {string} event + * @param {Component} component + */ + doNotListenTo(component, event) { + this.listeningTo[component.name]?.delete(event); + + if (this.listeningTo[component.name]?.size == 0) { + delete this.listeningTo[component.name]; + } + + if (!component.emitsTo(this, event)) return; + + component.doNotEmitTo(this, event); + } + + /** + * Stops this component from emitting to the other component + * @param {string} event + * @param {Component} component + * @returns + */ + doNotEmitTo(component, event) { + this.listening[event]?.delete(component.name); + + if (!component.listensTo(this, event)) return; + component.doNotListenTo(this, event); + } + + /** + * Get all Emitters declared in the component + */ + getDeclaredListeners() { + let obj = this; + let seen = new Set(); + + do { + if (!(obj instanceof Component)) break; + + for (let method of Object.getOwnPropertyNames(obj)) { + if (seen.has(method)) continue; + + if (typeof this[method] !== "function") continue; + if (method.length < 3) continue; + + if (!method.startsWith("$_")) continue; + + let meta = method.substring(1).split(/\$/g); + + let events = meta[0].split(/_/g); + events.shift(); + let cmpName = this.name; + + let subjects = meta.slice(1); + + if (!subjects?.length) subjects = [this.name, "on"]; + + let methods = { on: true, onAll: true }; + + let stack = []; + + for (let i = 0; i < subjects.length; i++) { + let current = subjects[i]; + stack.push(current); + + while (stack.length) { + i++; + current = subjects[i] ?? null; + + if (current && methods[current]) { + stack.push(current); + } else { + stack.push("on"); + i--; + } + + let m = stack.pop(); + let cmp = stack.pop(); + + for (let j = 0; j < events.length; j++) { + let ev = events[j]; + + if (!ev.length) continue; + + h[m](cmp, ev, (component, event, ...args) => { + try { + h + .getComponent(cmpName) + [method]?.bind( + h.getComponent(cmpName) + )(component, event, ...args); + } catch (e) { + console.error(e); + } + }); + } + } + } + + seen.add(method); + } + } while ((obj = Object.getPrototypeOf(obj))); + + const br = new BrokerRegistrar(); + + br.register(this); + } + /** + * Initializes the component and adds it to + * the component map of the markup engine + * @emits mounted + * @emits pre-mount + */ + async mount() { + h.component(this.name, this); + + this.claimListeners(); + this.emit(this.EVENTS.premount); + await this.bindComponent(); + this.emit(this.EVENTS.mounted); + } + + /** + * Deletes all the component's markup from the DOM + */ + unmount() { + let all = this.markup(); + + for (let elem of all) { + elem.remove(); + } + + this.releaseMemory(); + + return true; + } + + /** + * Checks if this component has + * elements on the dom and if they are + * visible + */ + checkVisibility() { + let elem = h.dom.querySelector(`ojs-${this.kebab(this.name)}`); + + if ( + elem && + elem.parentElement?.style.display !== "none" && + !this.visible + ) { + return this.show(); + } + + if ( + elem && + elem.parentElement?.style.display === "none" && + this.visible + ) { + return this.hide(); + } + + if ( + elem && + elem.style.display !== "none" && + elem.style.visibility !== "hidden" && + !this.visible + ) { + this.show(); + } + + if ( + (!elem || + elem.style.display === "none" || + elem.style.visibility === "hidden") && + this.visible + ) { + this.hide(); + } + } + + /** + * Emits an event + * @param {string} event + * @param {Array<*>} args + */ + emit(event, args = []) { + this.emitter.emit(event, this, event, ...args); + } + + /** + * Binds this component to the elements on the dom. + * @emits pre-bind + * @emits markup-bound + * @emits bound + */ + async bindComponent() { + this.emit(this.EVENTS.prebind); + + let all = h.dom.querySelectorAll( + `ojs-${this.kebab(this.name)}-tmp--` + ); + + if (all.length == 0 && !this.bindCalled) { + this.bindCalled = true; + setTimeout(this.bindComponent.bind(this), 500); + return; + } + + for (let elem of all) { + let hId = elem.getAttribute("ojs-key"); + + let args = [...h.compArgs.get(hId)]; + h.compArgs.delete(hId); + + this.wrap(...args, { parent: elem, replaceParent: true }); + + this.emit(this.EVENTS.markupBound, [elem, args]); + } + + this.emit(this.EVENTS.bound); + + return true; + } + + /** + * Converts camel case to kebab case + * @param {string} name + */ + kebab(name) { + let newName = ""; + + for (const c of name) { + if (c.toLocaleUpperCase() === c && newName.length > 1) + newName += "-"; + newName += c.toLocaleLowerCase(); + } + + return newName; + } + + /** + * Return all the current DOM elements for this component + * From the parent. + * @param {HTMLElement | null} parent + * @returns + */ + markup(parent = null) { + if (!parent) parent = h.dom; + + return parent.querySelectorAll(`ojs-${this.kebab(this.name)}`); + } + + /** + * Hides all the markup of this component + * @emits before-hidden + * @emits hidden + * @returns {bool} + */ + hide() { + this.emit(this.EVENTS.beforeHidden); + + let all = this.markup(); + + for (let elem of all) { + elem.style.display = "none"; + } + + this.emit(this.EVENTS.hidden); + + return true; + } + + /** + * Remove style-display-none from all this component's markup + * @emits before-visible + * @emits visible + * @returns bool + */ + show() { + this.emit(this.EVENTS.beforeVisible); + + let all = this.markup(); + + for (let elem of all) { + elem.style.display = ""; + } + + this.emit(this.EVENTS.visible); + + return true; + } + + /** + * Ensure that the action will get called + * even if the event was emitted previous + * @param {string} event + * @param {...function} listeners + */ + onAll(event, ...listeners) { + // check if we have previously emitted this event + listeners.forEach((a) => { + if (event in this.emitter.emitted) + a(...this.emitter.emitted[event]); + + this.emitter.on(event, a); + }); + } + + /** + * Add Event Listeners to that component + * @param {string} event + * @param {...function} listeners + */ + on(event, ...listeners) { + // check if we have previously emitted this event + listeners.forEach((a) => { + if (Array.isArray(a)) { + a.forEach((f) => this.emitter.on(event, f)); + return; + } + + this.emitter.on(event, a); + }); + } + + /** + * Gets all the listeners for itself and adds them to itself + */ + claimListeners() { + if (!h.eventsMap.has(this.name)) return; + + let events = h.eventsMap.get(this.name); + + for (let event in events) { + events[event].forEach((listener) => { + let func = listener.function; + + if (listener.type === "all") this.onAll(event, func); + else this.on(event, func); + }); + } + + h.eventsMap.delete(this.name); + } + + releaseMemory() { + this.cleanUp(); + + for (let event in this.listening) { + for (let [_name, component] of this.listening[event]) { + component.doNotListenTo(this, event); + } + } + + for (let id in this.states) { + this.states[id]?.off(this.name); + delete this.states[id]; + } + + this.argsMap = new Map(); + this.listeningTo = {}; + this.listening = {}; + + if (this.isAnonymous) { + this.emitter.listeners = {}; + this.emitter.emitted = {}; + } + } + + /** + * Renders the Element and returns an HTML Element + * @param {...any} args + * @returns {DocumentFragment|HTMLElement|String|Array} + */ + render(...args) { + return h.ojs(...args); + } + + /** + * Finds the parent in the argument list + * @param {Array<*>} args + * @returns + */ + getParentAndListen(args) { + let final = { + index: -1, + parent: null, + states: [], + resetParent: false, + replaceParent: false, + firstOfParent: false, + }; + + for (let i in args) { + if ( + args[i] instanceof State || + (args[i] && + typeof args[i].$__name__ !== "undefined" && + args[i].$__name__ == "OpenScript.State") + ) { + args[i].listener(this); + this.states[args[i].$__id__] = args[i]; + final.states.push(args[i].$__id__); + } else if ( + !( + args[i] instanceof DocumentFragment || + args[i] instanceof HTMLElement + ) && + args[i] && + !Array.isArray(args[i]) && + typeof args[i] === "object" && + args[i].parent + ) { + if (args[i].parent) { + final.index = i; + final.parent = args[i].parent; + } + + const keys = [ + "resetParent", + "replaceParent", + "firstOfParent", + ]; + + for (let reserved of keys) { + if (args[i][reserved]) { + final[reserved] = args[i][reserved]; + delete args[i][reserved]; + } + } + + delete args[i].parent; + } + } + + return final; + } + + /** + * Gets the value of object + * @param {any|State} object + * @returns + */ + getValue(object) { + if (object instanceof State) return object.value; + return object; + } + + /** + * Wraps the rendered content + * @emits re-rendered + * @param {...any} args + * @returns + */ + wrap(...args) { + const lastArg = args[args.length - 1]; + let { + index, + parent, + resetParent, + states, + replaceParent, + firstOfParent, + } = this.getParentAndListen(args); + + // check if the render was called due to a state change + if (lastArg && lastArg["called-by-state-change"]) { + let state = lastArg.self; + + delete args[index]; + + let current = + h.dom.querySelectorAll( + `ojs-${this.kebab(this.name)}[s-${state.$__id__}="${ + state.$__id__ + }"]` + ) ?? []; + + let reconciler = new this.Reconciler(); + + current.forEach((e) => { + if (!this.visible) e.style.display = "none"; + else e.style.display = ""; + + // e.textContent = ""; + + let arg = this.argsMap.get(e.getAttribute("uuid")); + let attr = { + // parent: e, + component: this, + event: this.EVENTS.rerendered, + eventParams: [{ markup: e, component: this }], + }; + + let shouldReconcile = true; + + if (e.childNodes.length === 0) { + attr.parent = e; + shouldReconcile = false; + } + + let markup = this.render(...arg, attr); + + if (shouldReconcile) { + if (Array.isArray(markup)) { + let newParent = e.cloneNode(); + newParent.append(...markup); + reconciler.reconcile(newParent, e); + } else { + reconciler.reconcile(markup, e.childNodes[0]); + } + } + }); + + return; + } + + let event = this.EVENTS.rendered; + + if ( + parent && + (this.getValue(resetParent) || this.getValue(replaceParent)) + ) { + if (!this.markup().length) this.argsMap.clear(); + else { + let all = this.markup(parent); + + all.forEach((elem) => + this.argsMap.delete(elem.getAttribute("uuid")) + ); + } + + if (this.argsMap.size) event = this.EVENTS.rerendered; + } + + let uuid = `${Component.uid++}-${new Date().getTime()}`; + + this.argsMap.set(uuid, args ?? []); + + let attr = { + uuid, + resetParent, + replaceParent, + firstOfParent, + class: "__ojs-c-class__", + }; + + if (parent) attr.parent = parent; + + states.forEach((id) => { + attr[`s-${id}`] = id; + }); + + let markup = this.render(...args, { withCAttr: true }); + + if ( + markup.tagName == Component.FRAGMENT && + markup.childNodes.length > 0 + ) { + let children = markup.childNodes; + + return children.length > 1 ? children : children[0]; + } + + if (!this.visible) attr.style = "display: none;"; + + let cAttributes = {}; + + if (markup instanceof HTMLElement) { + cAttributes = JSON.parse( + markup?.getAttribute("c-attr") ?? "{}" + ); + markup.setAttribute("c-attr", ""); + } + + attr = { + ...attr, + component: this, + event, + eventParams: [{ markup, component: this }], + }; + + return h[`ojs-${this.kebab(this.name)}`](attr, markup, cAttributes); + } + + isHtml(markup) { + return markup instanceof HTMLElement; + } + + /** + * Returns a mounted anonymous component's name. + */ + static anonymous() { + let id = Component.aCId++; + + let Cls = class extends Component { + constructor() { + super(); + this.name = `anonym-${id}`; + this.isAnonymous = true; + } + + /** + * Render function takes a state + * @param {State} state + * @param {Function} callback that returns the value to + * put in the markup + * @returns + */ + render(state, callback, ...args) { + let markup = callback(state, ...args); + return h[`ojs-wrapper`](markup, ...args); + } + }; + + let c = new Cls(); + c.getDeclaredListeners(); + c.mount(); + + return c.name; + } + + /** + * + * @param {string} eventName + * @param {function} listener + */ + addListener(eventName, listener) { + return this.on(eventName, listener); + } + + /** + * + * @param {string} eventName + * @param {function} listener + */ + removeListener(eventName, listener) { + return this.emitter.removeListener(eventName, listener); + } +} diff --git a/src/component/DOMReconciler.js b/src/component/DOMReconciler.js new file mode 100644 index 0000000..6e5cc45 --- /dev/null +++ b/src/component/DOMReconciler.js @@ -0,0 +1,215 @@ +/** + * DOMReconciler Class + */ +export default class DOMReconciler { + /** + * @param {Node} domNode + * @param {Node} newNode + */ + replace(domNode, newNode) { + try { + return domNode.parentNode.replaceChild(newNode, domNode); + } catch (e) { + console.error(e, domNode, domNode.parentNode); + } + } + + /** + * Replaces the attributes of node1 with that of node2 + * @param {HTMLElement} node1 + * @param {HTMLElement} node2 + */ + replaceAttributes(node1, node2) { + let length1 = node1.attributes.length; + let length2 = node2.attributes.length; + + let remove = []; + let add = []; + + let mx = Math.max(length1, length2); + + for (let i = 0; i < mx; i++) { + if (i >= length1) { + let attr = node2.attributes[i]; + add.push({ name: attr.name, value: attr.value }); + continue; + } + + if (i >= length2) { + let attr = node1.attributes[i]; + remove.push(attr.name); + continue; + } + + let attr1 = node1.attributes[i]; + let attr2 = node2.attributes[i]; + + if (!node2.hasAttribute(attr1.name)) { + remove.push(attr1.name); + } else if (attr1.value != node2.getAttribute(attr1.name)) { + add.push({ + name: attr1.name, + value: node2.getAttribute(attr1.name), + }); + } + + if (attr2.value != node1.getAttribute(attr2.name)) { + add.push({ name: attr2.name, value: attr2.value }); + } + } + + mx = Math.max(remove.length, add.length); + let mem = new Set(); + + for (let i = 0; i < mx; i++) { + if (i < remove.length && !mem.has(remove[i])) { + node1.removeAttribute(remove[i]); + } + if (i < add.length) { + node1.setAttribute(add[i].name, add[i].value); + mem.add(add[i].name); + } + } + } + + /** + * + * @param {Node} node1 + * @param {Node} node2 + * @returns + */ + equal(node1, node2) { + return node1?.isEqualNode(node2) == true; + } + + getEventListeners(node) { + if (!node.__eventListeners) { + node.__eventListeners = {}; + } + return node.__eventListeners || {}; + } + + replaceEventListeners(targetNode, sourceNode) { + const events = this.getEventListeners(targetNode); + + for (const eventName in events) { + events[eventName].forEach((listener) => { + targetNode.removeListener(eventName, listener); + }); + } + + const sourceEvents = this.getEventListeners(sourceNode); + + for (const eventName in sourceEvents) { + sourceEvents[eventName].forEach((listener) => { + targetNode.addListener(eventName, listener); + }); + } + } + + replaceAddedMethods(targetNode, sourceNode) { + if (!sourceNode.__methods) { + return; + } + + targetNode.__methods = {}; + + for (let m in sourceNode.__methods) { + targetNode.__methods[m] = sourceNode.__methods[m]; + } + + return; + } + + /** + * + * @param {Node|HTMLElement} current + * @param {Node|HTMLElement} previous - currently on the DOM + */ + reconcile(current, previous) { + if (this.isText(current)) { + this.replace(previous, current); + return true; + } + + this.replaceEventListeners(previous, current); + this.replaceAddedMethods(previous, current); + + if (this.equal(current, previous)) { + return false; + } + + if (this.isElement(current) && this.isElement(previous)) { + if (current.tagName !== previous.tagName) { + this.replace(previous, current); + return true; + } + + this.replaceAttributes(previous, current); + + if (this.equal(previous, current)) { + return false; + } + + let i = 0, + j = 0; + let prevLength = previous.childNodes.length; + let curLength = current.childNodes.length; + let _pc = curLength; + + while (i < prevLength && j < curLength) { + this.reconcile( + current.childNodes[j], + previous.childNodes[i] + ); + + _pc = curLength; + curLength = current.childNodes.length; + + if (_pc === curLength) j++; + + i++; + } + + while (i < previous.childNodes.length) { + previous.childNodes[i]?.remove(); + } + + while (j < current.childNodes.length) { + previous.append(current.childNodes[j]); + } + + return true; + } else { + this.replace(previous, current); + return true; + } + } + + /** + * + * @param {Node} node + */ + isText(node) { + return node.nodeType === Node.TEXT_NODE; + } + + /** + * + * @param {Node} node + * @returns + */ + isElement(node) { + return node.nodeType === Node.ELEMENT_NODE; + } + + /** + * + * @param {object} attr1 + * @param {object} attr2 + * @returns + */ + attributesEq(attr1, attr2) { + return JSON.stringify(attr1) == JSON.stringify(attr2); + } +} diff --git a/src/component/MarkupEngine.js b/src/component/MarkupEngine.js new file mode 100644 index 0000000..91b9350 --- /dev/null +++ b/src/component/MarkupEngine.js @@ -0,0 +1,683 @@ +import DOMReconciler from "./DOMReconciler.js"; +import Utils from "../utils/Utils.js"; +import { h } from "./h.js"; // Circular dependency? h is used in MarkupEngine +import Component from "./Component.js"; +import State from "../core/State.js"; + +/** + * Base Markup Engine Class + */ +export default class MarkupEngine { + /** + * The IDs for components on the DOM awaiting + * rendering + */ + static ID = 0; + + constructor() { + /** + * Keeps the components + * @type {Map} + */ + this.compMap = new Map(); + + /** + * Keeps the components arguments + * @type {Map} + */ + this.compArgs = new Map(); + + /** + * Keeps a temporary component-events map + * @type {Map>} + */ + this.eventsMap = new Map(); + + this.reconciler = new DOMReconciler(); + + /** + * References the DOM object + */ + this.dom = window.document; + + /** + * + * @param {string} name component name + * @param {Component} component OpenScript component for rendering. + * + * + * @return {HTMLElement|Array} + */ + this.component = (name, component) => { + if (!(typeof name === "string")) { + throw Error( + `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` + ); + } + + if (!(component instanceof Component)) { + throw new Error( + `MarkupEngine.Exception: The component for ${name} must be an Component component. ${component.constructor.name} given` + ); + } + + this.compMap.set(name, component); + }; + + /** + * Deletes the component from the Markup Engine Map. + * @emits unmount + * Removes an already registered company + * @param {string} name + * @param {boolean} withMarkup remove the markup of this component + * as well. + * @returns {boolean} + */ + this.deleteComponent = (name, withMarkup = true) => { + if (!this.has(name)) { + return false; + } + + if (withMarkup) this.getComponent(name).unmount(); + + this.getComponent(name).emit("unmount"); + + return this.compMap.delete(name); + }; + + /** + * Checks if a component is registered with the + * markup engine. + * @param {string} name + * @returns + */ + this.has = (name) => { + return this.compMap.has(name); + }; + + /** + * Checks if a component is registered + * @param {string} name + * @param {string} method method name + * @returns + */ + this.isRegistered = (name, method = "access") => { + if (this.has(name)) return true; + + console.warn( + `MarkupEngine.Warn: Trying to ${method} an unregistered component {${name}}. Please ensure that the component is registered by using h.has(componentName)` + ); + + return false; + }; + + this.reconcile = (domNode, newNode) => { + this.reconciler.reconcile(newNode, domNode); + }; + + /** + * Removes all a component's markup + * from the DOM + * @param {string} name + */ + this.hide = (name) => { + if (!this.isRegistered(name, "hide")) return false; + + const c = this.getComponent(name); + c.hide(); + + return true; + }; + + /** + * make all the component visible + * @param {string} name component name + * @returns + */ + this.show = (name) => { + if (!this.isRegistered(name, "show")) return false; + + const c = this.getComponent(name); + c.show(); + + return true; + }; + + this.modify = (element) => { + element.__eventListeners = element.__eventListeners ?? {}; + + element.addListener = function (event, listener) { + this.__eventListeners[event] = + this.__eventListeners[event] ?? []; + this.__eventListeners[event].push(listener); + this.addEventListener(event, listener); + }; + + element.removeListener = function (event, listener) { + this.__eventListeners[event] = this.__eventListeners[ + event + ]?.filter((x) => x !== listener); + + this.removeEventListener(event, listener); + }; + + element.getEventListeners = function () { + return this.__eventListeners; + }; + + if (!element.__methods) { + element.__methods = {}; + } + + element.methods = function () { + let methods = {}; + + for (let m in this.__methods) { + methods[m] = this.__methods[m].bind(this); + } + + return methods; + }; + }; + + this.fromString = (string, outerElement = "div", ...args) => { + let elem = h[outerElement](...args); + elem.innerHTML = string; + return elem; + }; + + /** + * handles the DOM element creation + * @param {string} name + * @param {...any} args + */ + this.handle = (name, ...args) => { + if (!(typeof name === "string")) { + throw Error( + `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` + ); + } + + if (/^[_\$]+$/.test(name)) { + name = Component.FRAGMENT.toLowerCase(); + } + + let isSvg = false; + + if (/^\$\w+$/.test(name)) { + name = name.substring(1); + isSvg = true; + } + + /** + * If this is a component, return it + */ + + if (this.compMap.has(name)) { + return this.compMap.get(name).wrap(...args); + } + + let component; + let event = ""; + let eventParams = []; + + const isComponentName = (tag) => { + return /^ojs-.*$/.test(tag); + }; + + /** + * + * @param {string} tag + */ + const getComponentName = (tag) => { + let name = tag + .toLowerCase() + .replace(/^ojs-/, "") + .replace(/-tmp--$/, ""); + + return Utils.camel(name, true); + }; + + /** + * @type {DocumentFragment|HTMLElement} + */ + let parent = null; + + let emptyParent = false; + let replaceParent = false; + let prependToParent = false; + let rootFrag = new DocumentFragment(); + + const isUpperCase = (string) => /^[A-Z]*$/.test(string); + let isComponent = isUpperCase(name[0]); + + /** + * @type {HTMLElement} + */ + let root = null; + + let componentAttribute = {}; + let withCAttr = false; + + /** + * When dealing with a component + * save the argument for async rendering + */ + if (isComponent) { + root = this.dom.createElement(`ojs-${Utils.kebab(name)}-tmp--`); + + let id = `ojs-${Utils.kebab(name)}-${MarkupEngine.ID++}`; + + root.setAttribute("ojs-key", id); + root.setAttribute("class", "__ojs-c-class__"); + + this.compArgs.set(id, args); + } else { + root = isSvg + ? this.dom.createElementNS( + "http://www.w3.org/2000/svg", + name + ) + : this.dom.createElement(name); + } + + this.modify(root); + + let parseAttr = (obj) => { + for (let k in obj) { + let v = obj[k]; + + if (v instanceof State) { + v = v.value; + } + + if (k === "parent" && v instanceof HTMLElement) { + parent = v; + continue; + } + + if (k === "resetParent" && typeof v === "boolean") { + emptyParent = v; + continue; + } + + if (k === "firstOfParent" && typeof v === "boolean") { + prependToParent = v; + continue; + } + + if (k === "event" && typeof v === "string") { + event = v; + continue; + } + + if (k === "replaceParent" && typeof v === "boolean") { + replaceParent = v; + continue; + } + + if (k === "eventParams") { + if (!Array.isArray(v)) v = [v]; + eventParams = v; + continue; + } + + if (k === "component" && v instanceof Component) { + component = v; + continue; + } + + if (k === "c_attr") { + componentAttribute = v; + continue; + } + + if (k.length && k[0] === "$") { + componentAttribute[k.substring(1)] = v; + continue; + } + + if (k === "withCAttr") { + withCAttr = true; + continue; + } + + if (k === "listeners") { + if (typeof v !== "object") { + throw TypeError( + `The value of 'listeners' should be an object. but found ${typeof v}` + ); + } + + for (let evt in v) { + let listener = v[evt]; + + if (Array.isArray(listener)) { + listener.forEach((l) => + root.addListener(evt, l) + ); + } else { + root.addListener(evt, listener); + } + } + + continue; + } + + if (k === "methods") { + if (typeof v !== "object") { + throw TypeError( + `The value of 'methods' attribute should be an object. but found ${typeof v}` + ); + } + + for (let method in v) { + let func = v[method]; + root.__methods[method] = func; + } + + continue; + } + + let val = `${v}`; + if (Array.isArray(v)) val = `${v.join(" ")}`; + + k = k.replace(/_/g, "-"); + + if (k === "class" || k === "Class") { + let cls = root.getAttribute(k) ?? ""; + val = cls + (cls.length > 0 ? " " : "") + `${val}`; + } + + try { + root.setAttribute(k, val); + } catch (e) { + console.error( + `MarkupEngine.ParseAttribute.Exception: `, + e, + `. Attributes resulting in the error: `, + obj + ); + throw Error(e); + } + } + }; + + const parse = (arg, isComp) => { + if ( + arg instanceof DocumentFragment || + arg instanceof HTMLElement || + arg instanceof SVGElement || + arg instanceof State + ) { + if (isComp) return true; + + if (arg instanceof State) { + typeof arg.value === "string" && + rootFrag.append(document.createTextNode(arg)); + } else { + rootFrag.append(arg); + } + + return true; + } + + if (typeof arg === "object") { + parseAttr(arg); + return true; + } + + if (typeof arg !== "undefined") { + rootFrag.append(arg); + return true; + } + + return false; + }; + + for (let arg of args) { + if (isComponent && parent) break; + + // if (arg instanceof State) continue; + + if ( + Array.isArray(arg) || + arg instanceof HTMLCollection || + arg instanceof NodeList + ) { + if (isComponent) continue; + + arg.forEach((e) => { + if (e) parse(e, isComponent); + }); + + continue; + } + + if (parse(arg, isComponent)) continue; + + if (isComponent) continue; + + let v = this.toElement(arg); + if (typeof v !== "undefined") rootFrag.append(v); + } + + root.append(rootFrag); + + if (withCAttr) { + let atr = JSON.stringify(componentAttribute); + if (atr) root.setAttribute("c-attr", atr); + } + + root.toString = function () { + return this.outerHTML; + }; + + if (parent) { + if (emptyParent) { + parent.textContent = ""; + } + + if (replaceParent) { + this.reconcile(parent, root); + } else if (prependToParent) { + parent.prepend(root); + } else { + parent.append(root); + } + } + + if (component) { + component.emit(event, eventParams); + + let sc = root.querySelectorAll(".__ojs-c-class__"); + sc.forEach((c) => { + if (!isComponentName(c.tagName.toLowerCase())) return; + let cmpName = getComponentName(c.tagName); + h.getComponent(cmpName)?.emit(event, eventParams); + }); + } + + return root; + }; + + /** + * Executes a function that returns an + * HTMLElement and adds that element to the overall markup. + * @param {function} f - This function should return an HTMLElement or a string or an Array of either + * @returns {HTMLElement|string|Array} + */ + this.call = (f = () => h["ojs-group"]()) => { + return f(); + }; + + /** + * Allows you to add functions to HTML elements + * @param {Array} ComponentAndMethod name of the method + * @param {...any} args arguments to pass to the method + * @returns + */ + this.func = (name, ...args) => { + let method = null; + let component = null; + + if (!Array.isArray(name)) { + method = name; + return `${method}(${this._escape(args)})`; + } + + method = name[1]; + component = name[0]; + + return `component('${component.name}')['${method}'](${this._escape( + args + )})`; + }; + + /** + * + * adds quotes to string arguments + * and serializes objects for + * param passing + * @note To escape adding quotes use ${string} + */ + this._escape = (args) => { + let final = []; + + for (let e of args) { + if (typeof e === "number") final.push(e); + else if (typeof e === "boolean") final.push(e); + else if (typeof e === "string") { + if (e.length && e.substring(0, 2) === "${") { + let length = + e[e.length - 1] === "}" ? e.length - 1 : e.length; + final.push(e.substring(2, length)); + } else final.push(`'${e}'`); + } else if (typeof e === "object") final.push(JSON.stringify(e)); + } + + return final; + }; + + this.__addToEventsMap = (component, event, listeners) => { + if (!this.eventsMap.has(component)) { + this.eventsMap.set(component, {}); + this.eventsMap.get(component)[event] = listeners; + return; + } + + if (!this.eventsMap.get(component)[event]) { + this.eventsMap.get(component)[event] = []; + } + + this.eventsMap.get(component)[event].push(...listeners); + }; + + /** + * Adds an event listener to a component + * @param {string|Array} component component name + * @param {string} event event name + * @param {...function} listeners listeners + */ + this.on = (component, event, ...listeners) => { + let components = component; + + if (!Array.isArray(component)) components = [component]; + + for (let component of components) { + if (/\./.test(component)) { + let tmp = component.split(".").filter((e) => e); + component = tmp[0]; + listeners.push(event); + event = tmp[1]; + } + + if (this.has(component)) { + this.getComponent(component).on(event, ...listeners); + + continue; + } + + listeners.forEach((f, i) => { + listeners[i] = { type: "after", function: f }; + }); + + this.__addToEventsMap(component, event, listeners); + } + }; + + /** + * Add events listeners to a component that will + * execute even after the event has been emitted + * @param {string|Array} component + * @param {string} event + * @param {...function} listeners + */ + this.onAll = (component, event, ...listeners) => { + let components = component; + + if (!Array.isArray(component)) components = [component]; + + for (let component of components) { + if (/\./.test(component)) { + let tmp = component.split(".").filter((e) => e); + component = tmp[0]; + listeners.push(event); + event = tmp[1]; + } + + if (this.has(component)) { + this.getComponent(component).onAll(event, ...listeners); + continue; + } + + listeners.forEach((f, i) => { + listeners[i] = { type: "all", function: f }; + }); + + this.__addToEventsMap(component, event, listeners); + } + }; + + /** + * Gets the event emitter of a component + * @param {string} component component name + * @returns + */ + this.emitter = (component) => { + return this.compMap.get(component)?.emitter; + }; + + /** + * Gets a component and returns it + * @param {string} name + * @returns {Component|null} + */ + this.getComponent = (name) => { + return this.compMap.get(name); + }; + + /** + * Creates an anonymous component + * around a state + * @param {State} state + * @param {Array} attribute attribute path + * @returns + */ + this.$anonymous = ( + state, + callback = (state) => state.value, + ...args + ) => { + return h[Component.anonymous()](state, callback, ...args); + }; + + /** + * Converts a value to HTML element; + * @param {string|HTMLElement} value + */ + this.toElement = (value) => { + return value; + }; + } +} diff --git a/src/component/MarkupHandler.js b/src/component/MarkupHandler.js new file mode 100644 index 0000000..6d6f72e --- /dev/null +++ b/src/component/MarkupHandler.js @@ -0,0 +1,39 @@ +import MarkupEngine from "./MarkupEngine.js"; + +/** + * Handler for the MarkupEngine + */ +export default class MarkupHandler { + static proxyInstance = null; + + constructor() { + let keys = Object.keys(new MarkupEngine()); + /** + * The reserved properties of the Markup engine + */ + this.reserved = new Map(); + keys.forEach((e) => this.reserved.set(e, true)); + } + + get(target, prop, receiver) { + if (this.reserved.has(prop)) { + return target[prop]; + } + + return (...args) => target.handle(prop, ...args); + } + + /** + * For Documentation, we return a proxy of Markup Engine + * @returns {MarkupEngine} + */ + static proxy() { + if (!MarkupHandler.proxyInstance) + MarkupHandler.proxyInstance = new Proxy( + new MarkupEngine(), + new MarkupHandler() + ); + + return MarkupHandler.proxyInstance; + } +} diff --git a/src/component/h.js b/src/component/h.js new file mode 100644 index 0000000..68f0738 --- /dev/null +++ b/src/component/h.js @@ -0,0 +1,3 @@ +import MarkupHandler from "./MarkupHandler.js"; + +export const h = MarkupHandler.proxy(); diff --git a/src/core/AutoLoader.js b/src/core/AutoLoader.js new file mode 100644 index 0000000..2c2a2f1 --- /dev/null +++ b/src/core/AutoLoader.js @@ -0,0 +1,413 @@ +import Component from "../component/Component.js"; +import { h } from "../component/h.js"; +import { namespace } from "../utils/helpers.js"; + +/** + * AutoLoads a class from a file + */ +export default class AutoLoader { + /** + * Keeps track of the files that have been loaded + */ + static history = new Map(); + + /** + * + * @param {string} dir Directory from which the file should be loaded + * @param {string} extension the extension of the file .js by default + */ + constructor(dir = ".", version = "1.0.0") { + /** + * The Directory or URL in which all JS files are located + */ + this.dir = "."; + + /** + * The extension of the files + */ + this.extension = ".js"; + + /** + * The version of the files. It will be appended as ?v=1.0 for example + * This enable fresh reloading if necessary + */ + this.version = "1.0.0"; + + this.dir = dir; + this.version = version; + } + + /** + * Changes . to forward slashes + * @param {string|Array} text + * @returns + */ + normalize(text) { + if (text instanceof Array) { + return text.join("/"); + } + return text.replace(/\./g, "/"); + } + + /** + * Changes / to . + * @param {string|Array} text + * @returns + */ + dot(text) { + if (text instanceof Array) { + return text.join("."); + } + return text.replace(/\//g, "."); + } + + /** + * Splits a file into smaller strings + * based on the class in that file + */ + Splitter = class Splitter { + /** + * Gets the class Signature + * @param {string} content + * @param {int} start + * @param {object<>} signature {name: string, signature: string, start: number, end: number} + */ + classSignature(content, start) { + const signature = { + name: "", + definition: "", + start: -1, + end: -1, + parent: null, + }; + + let startAt = start; + + let output = []; + let tmp = ""; + + let pushTmp = (index) => { + if (tmp.length === 0) return; + + if (output.length === 0) startAt = index; + + output.push(tmp); + tmp = ""; + }; + + for (let i = start; i < content.length; i++) { + let ch = content[i]; + + if (/[\s\r\t\n]/.test(ch)) { + pushTmp(i); + + continue; + } + + if (/\{/.test(ch)) { + pushTmp(i); + signature.end = i; + + break; + } + + tmp += ch; + } + + signature.start = startAt; + + if (output.length && output[0] !== "class") { + let temp = []; + temp[0] = output[0]; + temp[1] = output.splice(1).join(" "); + output = temp; + } + + if (output.length % 2 !== 0) + throw Error( + `Invalid Class File. Could not parse \`${content}\` from index ${start} because it doesn't have the proper syntax. ${content.substring( + start + )}` + ); + + if (output.length > 2) { + signature.parent = output[3]; + } + + signature.name = output[1]; + signature.definition = output.join(" "); + + return signature; + } + + /** + * Splits the content of the file by + * class + * @param {string} content file content + * @return {Map} class map + */ + classes(content) { + content = content.trim(); + + const stack = []; + const map = new Map(); + const qMap = new Map([ + [`'`, true], + [`"`, true], + ["`", true], + ]); + + let index = 0; + let code = ""; + + while (index < content.length) { + let signature = this.classSignature(content, index); + index = signature.end; + + let ch = content[index]; + stack.push(ch); + + code += signature.definition + " "; + code += ch; + + let text = []; + + index++; + + while (stack.length && index < content.length) { + ch = content[index]; + code += ch; + + if (qMap.has(ch)) { + text.push(ch); + index++; + + while (text.length && index < content.length) { + ch = content[index]; + code += ch; + + let last = text.length - 1; + + if (qMap.has(ch) && ch === text[last]) { + text.pop(); + } else if ( + ch === "\n" && + (text[last] === '"' || text[last] === "'") + ) { + text.pop(); + } + + index++; + } + continue; + } + if (/\{/.test(ch)) stack.push(ch); + if (/\}/.test(ch)) stack.pop(); + + index++; + } + + signature.name = signature.name.split(/\(/)[0]; + + map.set(signature.name, { + extends: signature.parent, + code, + name: signature.name, + signature: signature.definition, + }); + + code = ""; + } + + return map; + } + }; + + /** + * + * @param {string} fileName script name without the .js. + */ + async req(fileName) { + if (!/^[\w\._-]+$/.test(fileName)) + throw Error( + `OJS-INVALID-FILE: '${fileName}' is an invalid file name` + ); + + let names = fileName.split(/\./); + + if (AutoLoader.history.has(`${this.dir}.${fileName}`)) + return AutoLoader.history.get( + `${this.dir}.${fileName}` + ); + + let response = await fetch( + `${this.dir}/${this.normalize(fileName)}${this.extension}?v=${ + this.version + }`, + { + headers: { "x-powered-by": "OpenScriptJs" }, + } + ); + + let classes = await response.text(); + let content = classes; + + let classMap = new Map(); + let codeMap = new Map(); + let basePrefix = ""; + + try { + let url = new URL(this.dir); + basePrefix = this.dot(url.pathname); + } catch (e) { + basePrefix = this.dot(this.dir); + } + + let prefixArray = [ + ...basePrefix.split(/\./g).filter((v) => v.length), + ...names, + ]; + + let prefix = prefixArray.join("."); + if (prefix.length > 0 && !/^\s+$/.test(prefix)) prefix += "."; + + let splitter = new this.Splitter(); + + classes = splitter.classes(content); + + for (let [k, v] of classes) { + let key = prefix + k; + classMap.set(key, [k, v.code]); + } + + for (let [k, arr] of classMap) { + let parent = classes.get(arr[0]).extends; + + if (parent) { + let original = parent; + + if (!/\./g.test(parent)) parent = prefix + parent; + + if (!this.exists(parent)) { + if (!classMap.has(parent)) { + await this.req(parent); + } else { + let pCode = classMap.get(parent); + + prefixArray.push(pCode[0]); + + let code = await this.setFile( + prefixArray, + Function(`return (${pCode[1]})`)() + ); + + prefixArray.pop(); + + codeMap.set(parent, [pCode[0], code]); + } + } else { + let signature = classes.get(arr[0]).signature; + + let replacement = signature.replace(original, parent); + + let c = arr[1].replace(signature, replacement); + arr[1] = c; + } + } + + if (!this.exists(k)) { + prefixArray.push(arr[0]); + + let code = await this.setFile( + prefixArray, + Function(`return (${arr[1]})`)() + ); + + prefixArray.pop(); + + codeMap.set(k, [arr[0], code]); + } + } + + AutoLoader.history.set( + `${this.dir}.${fileName}`, + codeMap + ); + + return codeMap; + } + + async include(fileName) { + try { + return await this.req(fileName); + } catch (e) {} + + return null; + } + + /** + * Adds a class file to the window + * @param {Array} names + */ + async setFile(names, content) { + namespace(names[0]); + + let obj = window; + let final = names.slice(0, names.length - 1); + + for (const n of final) { + if (!obj[n]) obj[n] = {}; + obj = obj[n]; + } + + obj[names[names.length - 1]] = content; + + // Init the component if it is a + // component + + if (content.prototype instanceof Component) { + let c = new content(); + + if (h.has(c.name)) return; + c.getDeclaredListeners(); + await c.mount(); + } + // if component is function, register it. + else if (typeof content === "function" && !this.isClass(content)) { + let c = new Component(content.name); + + if (h.has(c.name)) return; + + c.render = content.bind(c); + c.getDeclaredListeners(); + await c.mount(); + } + + return content; + } + + isClass(func) { + return ( + typeof func === "function" && + /^class\s/.test(Function.prototype.toString.call(func)) + ); + } + + /** + * Checks if an object exists in the window + * @param {string} qualifiedName + */ + exists = (qualifiedName) => { + let names = qualifiedName.split(/\./); + let obj = window[names[0]]; + + for (let i = 1; i < names.length; i++) { + if (!obj) return false; + obj = obj[names[i]]; + } + + if (!obj) return false; + + return true; + }; +} diff --git a/src/core/Context.js b/src/core/Context.js new file mode 100644 index 0000000..1bbdcb2 --- /dev/null +++ b/src/core/Context.js @@ -0,0 +1,52 @@ +/** + * The Base Context Class for OpenScript + */ +export default class Context { + constructor() { + /** + * Let us know if this context was loaded from the network + */ + this.__fromNetwork__ = false; + + /** + * Keeps special keys + */ + this.$__specialKeys__ = new Map(); + this.__contextName__ = this.constructor.name + "Context"; + this.__referenceName__ = this.__contextName__; + + for (const key in this) { + this.$__specialKeys__.set(key, true); + } + } + + /** + * Puts a value in the context + * @param {string} name + * @param {*} value + */ + put(name, value = {}) { + this[name] = value; + } + + /** + * Get a value from the context + * @param {string} name + * @returns + */ + get(name) { + return this[name]; + } + + /** + * Reconciles all states in the temporary context with the loaded context + * @param {Map} map + * @param {string} key + */ + reconcile(map, key) { + // Implementation needed + // Assuming this method exists in the original code, but I need to see the rest of it. + // I'll leave it as a placeholder for now or implement if I saw it. + // I saw the start of it in the previous view_file. + } +} diff --git a/src/core/ContextProvider.js b/src/core/ContextProvider.js new file mode 100644 index 0000000..fff1a3e --- /dev/null +++ b/src/core/ContextProvider.js @@ -0,0 +1,153 @@ +import Context from "./Context.js"; +import ProxyFactory from "./ProxyFactory.js"; +// import AutoLoader from "./AutoLoader.js"; // Need to find AutoLoader + +/** + * The base Context Provider + */ +export default class ContextProvider { + /** + * The directory in which the Context + * files are located + */ + static directory; + + /** + * The version number for the network request to + * get updated files + */ + static version; + + constructor() { + /** + * The Global Context + */ + this.globalContext = {}; + + /** + * Context mapping + */ + this.map = new Map(); + + /** + * Adds a Context Path to the Map + * @param {string|Array} referenceName + * @param {string} qualifiedName The Context File path, ignoring the context directory itself. + * @param {boolean} fetch Should the file be fetched from the backend + * @param {boolean} load Should this context be loaded automatically + */ + this.put = async (referenceName, qualifiedName, fetch = false) => { + + if (!Array.isArray(referenceName)) + referenceName = [referenceName]; + + let c = this.map.get(referenceName[0]); + + let shouldFetch = false; + + if (!c || (c && !c.__fromNetwork__ && fetch)) + shouldFetch = true; + + if (shouldFetch) { + // Assuming AutoLoader is available + /* + let ContextClass = fetch + ? await new AutoLoader( + ContextProvider.directory, + ContextProvider.version + ).include(qualifiedName) + : null; + + if (!ContextClass) { + ContextClass = new Map([ + qualifiedName, + ["_", Context], + ]); + } + + let counter = 0; + + for (let [k, v] of ContextClass) { + try { + let cxt = new v[1](); + + let key = + referenceName[counter] ?? cxt.__contextName__; + + if (shouldFetch) cxt.reconcile(this.map, key); + + this.map.set(key, cxt); + } catch (e) { + console.error( + `Unable to load '${referenceName}' context because it already exists in the window. Please ensure that you are loading your contexts before your components`, + e + ); + } + + counter++; + } + */ + console.warn("ContextProvider.put is not fully implemented due to missing AutoLoader."); + } else { + console.warn( + `[${referenceName}] context already exists. If you have multiple contexts in the file in ${qualifiedName}, then you can use context('[contextName]Context') or the aliases you give them to access them.` + ); + } + + return this.context(referenceName); + }; + } + + /** + * Gets the Context with the given name. + * @note The name must be in the provider's map + * @param {string} name + */ + context(name) { + return this.map.get(name); + } + + /** + * Asynchronously loads a context + * @param {string|Array} referenceName + * @param {string} qualifiedName + * @param {boolean} fetch + */ + load(referenceName, qualifiedName, fetch = false) { + if (!Array.isArray(referenceName)) referenceName = [referenceName]; + + for (let name of referenceName) { + let c = this.map.get(name); + + if (!c) { + this.map.set(name, new Context()); + } + } + + this.put(referenceName, qualifiedName, fetch); + + return referenceName.length === 1 + ? this.map.get(referenceName[0]) + : this.map; + } + + /** + * Refreshes the whole context + */ + refresh() { + this.map.clear; + } + + static create() { + return ProxyFactory.make( + ContextProvider, + class { + set(target, prop, receiver) { + throw new Error( + "You cannot Set any Property on the ContextProvider" + ); + } + } + ); + } +} diff --git a/src/core/Emitter.js b/src/core/Emitter.js new file mode 100644 index 0000000..9eb87d4 --- /dev/null +++ b/src/core/Emitter.js @@ -0,0 +1,78 @@ +/** + * Event Emitter Class + */ +export default class Emitter { + constructor() { + this.listeners = {}; + /** + * List of emitted events + */ + this.emitted = {}; + } + + addListener(eventName, fn) { + this.listeners[eventName] = this.listeners[eventName] || []; + this.listeners[eventName].push(fn); + return this; + } + // Attach event listener + on(eventName, fn) { + return this.addListener(eventName, fn); + } + + // Attach event handler only once. Automatically removed. + once(eventName, fn) { + this.listeners[eventName] = this.listeners[eventName] || []; + const onceWrapper = (...args) => { + fn(...args); + this.off(eventName, onceWrapper); + }; + this.listeners[eventName].push(onceWrapper); + return this; + } + + // Alias for removeListener + off(eventName, fn) { + return this.removeListener(eventName, fn); + } + + removeListener(eventName, fn) { + let lis = this.listeners[eventName]; + if (!lis) return this; + for (let i = lis.length; i > 0; i--) { + if (lis[i] === fn) { + lis.splice(i, 1); + break; + } + } + return this; + } + + // Fire the event + emit(eventName, ...args) { + this.emitted[eventName] = args; + + let fns = this.listeners[eventName]; + if (!fns) return false; + fns.forEach((f) => { + try { + f(...args); + } catch (e) { + console.error(e); + } + }); + return true; + } + + listenerCount(eventName) { + let fns = this.listeners[eventName] || []; + return fns.length; + } + + // Get raw listeners + // If the once() event has been fired, then that will not be part of + // the return array + rawListeners(eventName) { + return this.listeners[eventName]; + } +} diff --git a/src/core/EventData.js b/src/core/EventData.js new file mode 100644 index 0000000..d06a67e --- /dev/null +++ b/src/core/EventData.js @@ -0,0 +1,104 @@ +/** + * The Event Data class + */ +export default class EventData { + constructor() { + /** + * The Meta Data + */ + this._meta = {}; + + /** + * Message containing the args + */ + this._message = {}; + } + + meta(data) { + this._meta = data; + return this; + } + + message(data) { + this._message = data; + return this; + } + + /** + * Convert the Event Schema to string + * @returns {string} + */ + encode() { + return JSON.stringify(this); + } + + /** + * JSON.parse + * @param {string} str + * @returns {EventData} + */ + static decode(str) { + return JSON.parse(str); + } + /** + * Parse and Event Data + * @param {string} eventData + * @returns + */ + static parse(eventData) { + let ed = EventData.decode(eventData); + + if (!("_meta" in ed)) ed._meta = {}; + if (!("_message" in ed)) ed._message = {}; + + return { + meta: { + ...ed._meta, + has: function (key) { + return key in this; + }, + get: function (key, def = null) { + return this[key] ?? def; + }, + put: function (key, value) { + this[key] = value; + return this; + }, + remove: function (key) { + delete this[key]; + return this; + }, + getAll: function () { + return ed._meta; + }, + }, + message: { + ...ed._message, + has: function (key) { + return key in this; + }, + get: function (key, def = null) { + return this[key] ?? def; + }, + put: function (key, value) { + this[key] = value; + return this; + }, + remove: function (key) { + delete this[key]; + return this; + }, + getAll: function () { + return ed._message; + }, + }, + encode: function () { + // Reconstructing the object to match EventData structure for encoding + let newEd = new EventData(); + newEd._meta = this.meta; + newEd._message = this.message; + return newEd.encode(); + }, + }; + } +} diff --git a/src/core/ProxyFactory.js b/src/core/ProxyFactory.js new file mode 100644 index 0000000..5bc09b8 --- /dev/null +++ b/src/core/ProxyFactory.js @@ -0,0 +1,14 @@ +/** + * Creates a Proxy + */ +export default class ProxyFactory { + /** + * Makes a Proxy + * @param {class} Target + * @param {class} Handler + * @returns + */ + static make(Target, Handler) { + return new Proxy(new Target(), new Handler()); + } +} diff --git a/src/core/Runner.js b/src/core/Runner.js new file mode 100644 index 0000000..ea24429 --- /dev/null +++ b/src/core/Runner.js @@ -0,0 +1,43 @@ +import Component from "../component/Component.js"; +import Mediator from "../mediator/Mediator.js"; +import Listener from "../broker/Listener.js"; +import Context from "./Context.js"; +import { isClass } from "../utils/helpers.js"; + +/** + * Used to Initialize and Register/Mount Classes upon creation + */ +export default class Runner { + isClass(func) { + return isClass(func); + } + + async run(...cls) { + for (let i = 0; i < cls.length; i++) { + let c = cls[i]; + let instance; + + if (!this.isClass(c)) { + instance = new Component(c.name); + instance.render = c.bind(instance); + } else { + instance = new c(); + } + + if (instance instanceof Component) { + instance.getDeclaredListeners(); + instance.mount(); + } else if ( + instance instanceof Mediator || + instance instanceof Listener + ) { + instance.register(); + } else if (instance instanceof Context) { + } else { + throw Error( + `You can only pass declarations which extend Component, Mediator or Listener` + ); + } + } + } +} diff --git a/src/core/State.js b/src/core/State.js new file mode 100644 index 0000000..f4e2322 --- /dev/null +++ b/src/core/State.js @@ -0,0 +1,246 @@ +import ProxyFactory from "./ProxyFactory.js"; + +/** + * The main State class + */ +export default class State { + /** + * The count of the number of states in the program + */ + static count = 0; + + static VALUE_CHANGED = "value-changed"; + + constructor() { + /** + * The value of the state + */ + this.value; + + /** + * ID of this state + */ + this.$__id__; + + /** + * Has this state changed + */ + this.$__changed__ = false; + + this.$__name__ = "OpenScript.State"; + + this.$__CALLBACK_ID__ = 0; + + /** + * Tells the component to rerender + */ + this.$__signature__ = { + "called-by-state-change": true, + self: this, + }; + + this.$__listeners__ = new Map(); + } + + /** + * Add a component that listens to this state + * @param {Component|Function} listener + * @returns + */ + listener(listener) { + // Assuming Component check via duck typing or name if circular dependency prevents instanceof + if (listener && listener.name && typeof listener.wrap === 'function') { + this.$__listeners__.set(listener.name, listener); + return listener.name; + } else { + let id = this.$__CALLBACK_ID__++; + this.$__listeners__.set(`callback-${id}`, listener); + return `callback-${id}`; + } + } + + /** + * Adds a listener that is automatically removed once the event is fired + * @param {Component|Function} listener + * @returns + */ + once(listener) { + let id = null; + let onceWrapper = null; + + if (listener && listener.name && typeof listener.wrap === 'function') { + id = listener.name; + + onceWrapper = { + name: id, + + wrap: ((...args) => { + this.off(id); + return listener.wrap(...args); + }).bind(this), + }; + } else { + id = `callback-${this.$__CALLBACK_ID__++}`; + onceWrapper = ((...args) => { + this.off(id); + return listener(...args); + }).bind(this); + } + + this.$__listeners__.set(id, onceWrapper); + + return id; + } + + /** + * Removes a Component + * @param {string} id + * @returns + */ + off(id) { + return this.$__listeners__.delete(id); + } + + /** + * Fires on state change + * @param {...any} args + * @returns + */ + async fire(...args) { + for (let [k, listener] of this.$__listeners__) { + if (/^callback-\d+$/.test(k)) { + listener(this, ...args); + } else { + listener.wrap(...args, this.$__signature__); + } + } + + return this; + } + + *[Symbol.iterator]() { + if (typeof this.value !== "object") { + yield this.value; + } else { + for (let k in this.value) { + yield this.value[k]; + } + } + } + + toString() { + return `${this.value}`; + } + + /** + * Creates a new State + * @param {any} value + * @returns {State} + */ + static state(v = null) { + return ProxyFactory.make( + class extends State { + constructor() { + super(); + this.value = v; + this.$__id__ = State.count++; + } + + push = (...args) => { + if (!Array.isArray(this.value)) { + throw Error( + "State.Exception: Cannot execute push on a state whose value is not an array" + ); + } + + this.value.push(...args); + this.$__changed__ = true; + + this.fire(); + }; + }, + class { + set(target, prop, value) { + if (prop === "value") { + let current = target.value; + let nVal = value; + + if (typeof nVal !== "object" && current === nVal) + return true; + + Reflect.set(...arguments); + + target.$__changed__ = true; + + target.fire(); + + return true; + } else if ( + !( + prop in + { + $__listeners__: true, + $__signature__: true, + $__CALLBACK_ID__: true, + } + ) && + target.value[prop] !== value + ) { + target.value[prop] = value; + target.$__changed__ = true; + + target.fire(); + + return true; + } + + return Reflect.set(...arguments); + } + + get(target, prop, receiver) { + if ( + prop === "length" && + typeof target.value === "object" + ) { + return Object.keys(target.value).length; + } + + if ( + typeof prop !== "symbol" && + /\d+/.test(prop) && + Array.isArray(target.value) + ) { + return target.value[prop]; + } + + if ( + !target[prop] && + target.value && + typeof target.value === "object" && + target.value[prop] + ) + return target.value[prop]; + + return Reflect.get(...arguments); + } + + deleteProperty(target, prop) { + if (typeof target.value !== "object") return false; + + if (Array.isArray(target.value)) { + target.value = target.value.filter( + (v, i) => i != prop + ); + } else { + delete target.value[prop]; + } + + target.$__changed__ = true; + target.fire(); + + return true; + } + } + ); + } +} diff --git a/src/fotastart.js b/src/fotastart.js deleted file mode 100644 index 366abd2..0000000 --- a/src/fotastart.js +++ /dev/null @@ -1,918 +0,0 @@ -/** - * The Doc Class provides shorted API for the document model that is available in the windows object. - */ -export class dom { - /** - * Returns a new DOM object. Add this to the window using `window.dom = new Dom()`; - */ - constructor() {} - - /** - * Gets a single element - * @param {string} selector css selector - * @param {HTMLElement} parent defaults to document - */ - static get(selector, parent = null) { - if (!parent) parent = document; - return parent.querySelector(selector); - } - - /** - * Gets all the elements - * @param {string} selector css selector - * @param {HTMLElement} parent defaults to document - * @returns - */ - static all(selector, parent = null) { - if (!parent) parent = document; - return parent.querySelectorAll(selector); - } - - /** - * Gets the first element from the selected node list - * @param {string} selector css selector - * @param {HTMLElement} parent defaults to document - */ - static first(selector, parent = null) { - let list = this.all(selector, parent); - return list.length == 0 ? null : list[0]; - } - - /** - * Gets the last element from the select node list - * @param {string} selector css selector - * @param {HTMLElement} parent defaults to document - */ - static last(selector, parent = null) { - let list = this.all(selector, parent); - return list.length == 0 ? null : list[list.length - 1]; - } - - /** - * Get element at a position - * @param {string} selector css selector - * @param {int} position - * @param {HTMLElement} parent defaults to document - * @returns - */ - static at(selector, position, parent = null) { - let list = this.all(selector, parent); - return list.length == 0 ? null : list[position]; - } - - /** - * Creates an HTML element - * @param {string} elementType - * @returns - */ - static element(elementType) { - return document.createElement(elementType); - } - - /** - * Puts an inner html in an element - * @param {HTMLElement} element - * @param {string} innerHTML - * @param {bool} append append to current html? - */ - static put(innerHTML, element, append = false) { - if (append) { - element.innerHTML += innerHTML; - return; - } - - element.innerHTML = innerHTML; - } - - /** - * ***document.getElementById(id)*** - * @param {string} id - * @param {HTMLElement} parent defaults to document - * @returns {HTMLElement|null} - */ - static id(id, parent = null) { - if (!parent) parent = document; - - return parent.getElementById(`${id}`); - } - - /** - * ***document.getElementsByClass(class)*** - * @param {string} className - * @param {HTMLElement} parent defaults to document - * @returns - */ - static class(className, parent = null) { - return dom.all(`.${className}`, parent); - } - - /** - * Sets innerHTML to empty string - * @param {HTMLElement} element - */ - static empty(element) { - if (!element) return; - element.innerHTML = ""; - } - - /** - * Checks if the element has no innerHTML - * @param {HTMLElement} element - */ - static isEmpty(element) { - if (element?.value) return element?.value.length < 1; - - return /^[\t\r\n\s]*$/g.test(element?.innerHTML); - } - - /** - * Disables an element - * @param {HTMLElement} element - */ - static disable(element) { - element?.setAttribute("disabled", "true"); - } - - /** - * Enables an element - * @param {HTMLElement} element - */ - static enable(element) { - element.removeAttribute("disabled"); - } - - /** - * Centers an absolutely positioned element (y) inside another (x), - * regardless of where x is in the DOM. - * @param {HTMLElement} x - The container element (e.g., #x) - * @param {HTMLElement} y - The element to center (e.g., #y) - */ - static centerInside( - x, - y, - useOffset = true, - adjustLeft = null, - adjustTop = null - ) { - if (!x || !y) { - console.warn("Both x and y elements must be provided"); - return; - } - - let top = 0; - let left = 0; - - if (useOffset) { - top = x.offsetTop + (x.offsetHeight / 2); - left = x.offsetLeft + (x.offsetWidth / 2); - } else { - const xRect = x.getBoundingClientRect(); - - // x's position relative to the page - const xTop = xRect.top; - const xLeft = xRect.left; - - // Compute center position relative to x's top-left corner - top = xTop + xRect.height / 2; - left = xLeft + xRect.width / 2; - } - - if(adjustLeft) left += adjustLeft; - if(adjustTop) top += adjustTop; - - y.style.top = `${top}px`; - y.style.left = `${left}px`; - } -} - -/** - * Tools class contains utility functions - */ -export class tool { - static emailFormat = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/; - - constructor() {} - - static addToWindow(...args) { - for (let item of args) { - if ( - (typeof item === "function" || typeof item === "object") && - item !== null - ) { - let name; - - // For classes and functions - if (typeof item === "function") { - name = Function.prototype.hasOwnProperty.call(item, "name") - ? Function.prototype.toString - .call(item) - .match(/(?:class|function)\s+([^\s({]+)/)?.[1] - : item.name; - } - // For object instances, use constructor - else if (typeof item === "object" && item.constructor) { - name = item.constructor.name; - } - - if (name) { - window[name] = item; - if (tool.isLocal()) { - console.log(`[makeGlobal] window.${name} registered.`); - } - } else { - if (tool.isLocal()) { - console.warn( - "[makeGlobal] Skipped unnamed item:", - item - ); - } - } - } - } - } - - /** - * Builds **FormData** Object from a JSON object - * @param {object} data - * @returns - */ - static formData(data) { - let formData = new FormData(); - - for (const key in data) { - formData.append(key, data[key]); - } - - return formData; - } - - /** - * - * @param {string} url - * @returns - */ - static redirect(url) { - return (window.location.href = url); - } - - /** - * Reloads the page - */ - static reload() { - return window.location.reload(); - } - - /** - * Goes back or forward certain levels - * @param {number} level - */ - static back(level = 0) { - if (level !== 0) { - return window.history.go(level); - } - - return window.history.back(); - } - - /** - * Converts text to JSON - * @param {string} text - * @returns - */ - static json(text) { - return JSON.parse(text); - } - - /** - * Converts an array to Object - * @param {any[]} arr - * @returns - */ - static toObject(arr) { - let rv = {}; - for (const k in arr) { - if (arr[k]) rv[k] = arr[k]; - } - return rv; - } - - /** - * Takes a url, updates the query string, and returns the updated url. - * @author https://stackoverflow.com/users/822711/popnoodles - * @param {string} key key to search for in the query string. If the key doesn't exist, it will be added. - * @param {string} value new value to give that key. If null, - * the key will be removed from the url - * @param {string} url the url to update. defaults to the current url is left empty. - * - * @return {string} updated url - */ - static url(key, value, url = null) { - if (!url) url = window.location.href; - var re = new RegExp("([?&])" + key + "=.*?(&|#|$)(.*)", "gi"), - hash; - - if (re.test(url)) { - if (typeof value !== "undefined" && value !== null) { - return url.replace(re, "$1" + key + "=" + value + "$2$3"); - } else { - hash = url.split("#"); - url = hash[0].replace(re, "$1$3").replace(/(&|\?)$/, ""); - if (typeof hash[1] !== "undefined" && hash[1] !== null) { - url += "#" + hash[1]; - } - return url; - } - } else { - if (typeof value !== "undefined" && value !== null) { - var separator = url.indexOf("?") !== -1 ? "&" : "?"; - hash = url.split("#"); - url = hash[0] + separator + key + "=" + value; - if (typeof hash[1] !== "undefined" && hash[1] !== null) { - url += "#" + hash[1]; - } - return url; - } else { - return url; - } - } - } - - /** - * Converts JSON object to url query string - * @param {object} data - * @returns - */ - static toQueryString(data) { - let qs = ""; - for (const key in data) { - if (qs != "") qs += "&"; - qs += `${key}=${data[key]}`; - } - - return qs; - } - - static toClipboard(text) { - if (!navigator.clipboard) { - tool.fallbackToClipboard(text); - return; - } - navigator.clipboard.writeText(text).then( - function () { - inform("Copied", STRINGS.INFO); - }, - function (err) { - inform("Unable to copy", STRINGS.WARNING); - } - ); - } - - static fallbackToClipboard(text) { - try { - let textbox = document.createElement("input"); - textbox.value = text; - document.body.appendChild(textbox); - textbox.select(); - document.execCommand("copy"); - document.body.removeChild(textbox); - inform("Copied", STRINGS.INFO); - } catch (e) { - inform("Unable to copy", STRINGS.WARNING); - } - } - - static isLocal() { - return Boolean( - window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1" || - // ::1 is IPv6 localhost - window.location.hostname === "::1" || - // 192.168.x.x or 10.x.x.x or 172.16.x.x – private IP ranges - /^192\.168\./.test(window.location.hostname) || - /^10\./.test(window.location.hostname) || - /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(window.location.hostname) - ); - } - - /** - * - * @param {Object<>} object - * @returns {Object<>} new object - */ - static deepCopy(object) { - return JSON.parse(JSON.stringify(object)); - } - - /** - * Increments an HTML element value - * @param {HTMLElement} element - */ - static increment(element, max) { - let newValue = parseInt(element.value) + 1; - if (newValue > parseInt(max)) newValue = max; - element.value = newValue; - } - - /** - * Decrements an input value until 0 - * @param {HTMLElement} element - */ - static decrement(element) { - let value = parseInt(element.value) - 1; - if (value < 0) value = 0; - element.value = value; - } - - /** - * Gets a value from the element dataset - * - * @param {HTMLElement} elem - * @param {string} key - * @param {*} def - */ - static fromDataset(elem, key, def = null) { - return elem.dataset[key] ?? def; - } - - /** - * Gets a query string value from the URL - * @param {string} key - * @param {*} def - * @returns - */ - static fromUrl(key, def = null, url = null) { - let u = document.location; - if (url) u = new URL(url); - let params = new URLSearchParams(u.search); - - return params.get(key) ?? def; - } - - /** - * Checks if a string is empty - * @param {string} str - */ - static empty(str) { - return /^[\s\n\t\r]+$/.test(str) || str.length == 0; - } - - /** - * Converts all \n character to
- * @param {string} str - */ - static enterToBr(str) { - return str.replace(/\n+/g, "
"); - } -} - -/** - * Event Emitter allows you to use behavior based design pattern - */ -export class FSEventEmitter { - listeners = {}; - - /** - * Add Event Listener - * @param {string} eventName the event to listen for - * @param {Function} fn handler function - */ - addListener(eventName, fn) { - this.listeners[eventName] = this.listeners[eventName] || []; - this.listeners[eventName].push(fn); - return this; - } - - /** - * Adds Event Listener - * @param {string} eventName - * @param {Function} fn - */ - on(eventName, fn) { - return this.addListener(eventName, fn); - } - - /** - * Adds event listener to be executed once - * @param {string} eventName - * @param {Function} fn - * @returns - */ - once(eventName, fn) { - this.listeners[eventName] = this.listeners[eventName] || []; - const onceWrapper = () => { - fn(); - this.off(eventName, onceWrapper); - }; - this.listeners[eventName].push(onceWrapper); - return this; - } - - /** - * Removes and event listener - * @param {string} eventName - * @param {Function} fn - * @returns - */ - off(eventName, fn) { - return this.removeListener(eventName, fn); - } - - /** - * Removes an event listener - * @param {string} eventName - * @param {Function} fn - * @returns - */ - removeListener(eventName, fn) { - let lis = this.listeners[eventName]; - if (!lis) return this; - for (let i = lis.length; i > 0; i--) { - if (lis[i] === fn) { - lis.splice(i, 1); - break; - } - } - return this; - } - - /** - * Fires an event - * @param {string} eventName - * @param {...any} args - * @return {true} true - */ - emit(eventName, ...args) { - let fns = this.listeners[eventName]; - if (!fns) return false; - fns.forEach((f) => { - f(...args); - }); - return true; - } - - /** - * Returns the number of listeners for this event - * @param {string} eventName - * @returns - */ - listenerCount(eventName) { - let fns = this.listeners[eventName] || []; - return fns.length; - } - - /** - * Get raw listeners - * If the once() event has been fired, then that will not be part of - * the return array - * - * @param {string} eventName - * @returns - */ - listeners(eventName) { - return this.listeners[eventName]; - } -} - -/** - * A simple pipelining class - */ -export class Pipeline { - /** - * Filters object - * @type {Array} - */ - filters = []; - - /** - * Add a filter to this pipeline - * @param {...Filter} filters the filter function takes in an object - * and returns an object with - * two attributes: `{output: filterOutput, next: true|false}`. - * - * @returns {Pipeline} - */ - add(...filters) { - for (let filter of filters) { - this.filters.push(filter); - } - - return this; - } - - /** - * Pass data through the pipeline - * @param {*} data - */ - async pass(data) { - let output = data; - let next = true; - - for (let f of this.filters) { - let o = await f.run(output); - - output = o.data; - next = o.next; - - if (!next) return output; - } - - return output; - } - - /** - * Removes all filters from the pipeline - */ - reset() { - this.filters = []; - } -} - -/** - * The filter class - */ -export class Filter { - /** - * The logic to run - */ - logic; - - /** - * - * @param {Function} logic the logic to run - */ - constructor(logic) { - this.logic = logic; - } - - /** - * Runs the filter and returns a response - * @param {*} input - * @returns - */ - async run(input) { - let o = await this.logic(input); - - if (!("data" in o) || !("next" in o)) - throw Error( - `A filter must return an object with output and next property. This filter return:`, - o, - ` instead` - ); - - return o; - } - /** - * Creates the Filter output - * @param {*} data - * @param {boolean} next proceed to next filter - * @returns - */ - static output(data, next = true) { - return { data, next }; - } -} - -/** - * Network request class - */ -export class Requester { - /** - * @var {string} csrf token - */ - csrf; - - /** - * @var {string} base url - */ - baseUrl; - - /** - * Default headers - */ - headers = { - "Content-Type": "application/json", - Accept: "application/json", - }; - - /** - * Controls the aborting of a request - */ - abortController; - - /** - * The abort signal - */ - abortSignal; - - _pipeline = new Pipeline(); - - callCount = {}; - - /** - * Request Configs such as mode, cache, - * credentials, redirect, refererPolicy are placed here - */ - requestConfigs = {}; - - /** - * Creates a fota object - * @param {object<>} config - */ - constructor(config = { csrf, baseUrl }) { - this.baseUrl = config.baseUrl; - this.csrf = config.csrf; - this.abortController = new AbortController(); - this.abortSignal = this.abortController.signal; - } - - /** - * The pipeline through which the response - * from the request will be passed - * @param {Pipeline} pipeline - */ - pipeline(pipeline) { - this._pipeline = pipeline; - return this; - } - - /** - * Adds a default Filter to the pipeline - */ - defaultPipeline() { - this._pipeline.add( - new Filter( - /** - * - * @param {Response} response - * @returns - */ - async function (response) { - if (!response.ok) { - return Filter.output( - { status: "error", message: response.statusText }, - false - ); - } - - let data = await response.json(); - - return Filter.output(data, true); - } - ) - ); - - return this; - } - - /** - * - * Gets a new requester object with an empty Pipeline - */ - noPipeline() { - let r = new Requester(); - - for (let k in this) { - if (k === "_pipeline") continue; - - r[k] = this[k]; - } - - return r; - } - - /** - * Make a POST Request and pass the response through - * the object's pipeline. - * @param {string} path - * @param {object} body - * @param {object} headers - */ - async post(path, body, headers = {}) { - return await this._pipeline.pass( - await this.fetch(path, "post", body, headers) - ); - } - - /** - * makes a GET request. the body object - * will be automatically converted to - * a query string - * @param {string} path - * @param {object} body - * @param {object} headers - * @returns - */ - async get(path, body = {}, headers = {}) { - if (!this.callCount[path]) { - this.callCount[path] = 1; - } else { - this.callCount[path]++; - } - - if (!body) { - body = {}; - } - - body["_ftsct"] = this.callCount[path]; - - let qs = new URLSearchParams(body).toString(); - - return await this._pipeline.pass( - await this.fetch(`${path}?${qs}`, "get", null, headers) - ); - } - - /** - * Makes a DELETE request and passes the - * response through the default object's pipeline pipeline - * @param {string} path - * @param {object} body - * @param {object} headers - * @returns - */ - async delete(path, body, headers = {}) { - return await this._pipeline.pass( - await this.fetch(path, "delete", body, headers) - ); - } - - /** - * Makes a PUT request and passes the - * response through the default object's pipeline pipeline - * @param {string} path - * @param {object} body - * @param {object} headers - * @returns - */ - async put(path, body, headers = {}) { - return await this._pipeline.pass( - await this.fetch(path, "put", body, headers) - ); - } - - /** - * The underlying fetch method - * @param {string} path - * @param {string} method - * @param {object} body - * @param {object} headers - * @returns - */ - async fetch(path, method, body, headers) { - let configs = { - method, - headers: { - "X-CSRF-TOKEN": this.csrf, - ...this.headers, - ...headers, - }, - ...this.requestConfigs, - signal: this.abortSignal, - }; - - if (body) { - configs.body = body; - - if (configs.headers["Content-Type"] == "application/json") { - configs.body = JSON.stringify(body); - } - } - - let finalPath = ""; - path = path.trim(); - - if (/^https?:\/\//.test(path)) { - finalPath = path; - } else { - path = path.replace(/\/{2,}/g, "/"); - finalPath = `${this.baseUrl}${path[0] != "/" ? "/" : ""}${ - path ?? "" - }`; - } - - let response = await fetch(finalPath, configs); - - response.silent = headers.silent ?? false; - - return response; - } - - /** - * Aborts the current network request - */ - abort() { - this.abortController.abort(); - } -} - -export class IdGenerator { - #ID = 1; - - getId() { - return this.#ID++; - } - - toString() { - return this.getId(); - } -} diff --git a/src/grouper.mjs b/src/grouper.mjs deleted file mode 100644 index f15cc0c..0000000 --- a/src/grouper.mjs +++ /dev/null @@ -1,330 +0,0 @@ -import fs from "fs"; - -import config from "./ojs-config.json" with {type: "json"}; - -/** - * Splits a file into smaller strings - * based on the class in that file - */ -class Splitter { - /** - * Gets the class Signature - * @param {string} content - * @param {int} start - * @param {object<>} signature {name: string, signature: string, start: number, end: number} - */ - classSignature(content, start) { - const signature = { - name: "", - definition: "", - start: -1, - end: -1, - parent: null, - }; - - let startAt = start; - - let output = []; - let tmp = ""; - - let pushTmp = (index) => { - if (tmp.length === 0) return; - - if (output.length === 0) startAt = index; - - output.push(tmp); - tmp = ""; - }; - - for (let i = start; i < content.length; i++) { - let ch = content[i]; - - if (/[\s\r\t\n]/.test(ch)) { - pushTmp(i); - - continue; - } - - if (/\{/.test(ch)) { - pushTmp(i); - signature.end = i; - - break; - } - - tmp += ch; - } - - signature.start = startAt; - - if (output.length && output[0] !== "class") { - let temp = []; - temp[0] = output[0]; - temp[1] = output.splice(1).join(" "); - output = temp; - } - - if (output.length % 2 !== 0) - throw Error( - `Invalid Class File. Could not parse \`${content}\` from index ${start} because it doesn't have the proper syntax. ${content.substring( - start - )}` - ); - - if (output.length > 2) { - signature.parent = output[3]; - } - - signature.name = output[1]; - signature.definition = output.join(" "); - - return signature; - } - - /** - * Splits the content of the file by - * class - * @param {string} content file content - * @return {Map} class map - */ - classes(content) { - content = content.trim(); - - const stack = []; - const map = new Map(); - const qMap = new Map([ - [`'`, true], - [`"`, true], - ["`", true], - ]); - - let index = 0; - let code = ""; - - while (index < content.length) { - let signature = this.classSignature(content, index); - index = signature.end; - - let ch = content[index]; - stack.push(ch); - - code += signature.definition + " "; - code += ch; - - let text = []; - - index++; - - while (stack.length && index < content.length) { - ch = content[index]; - code += ch; - - if (qMap.has(ch)) { - text.push(ch); - index++; - - while (text.length && index < content.length) { - ch = content[index]; - code += ch; - - let last = text.length - 1; - - if (qMap.has(ch) && ch === text[last]) { - text.pop(); - } else if ( - ch === "\n" && - (text[last] === '"' || text[last] === "'") - ) { - text.pop(); - } - - index++; - } - continue; - } - if (/\{/.test(ch)) stack.push(ch); - if (/\}/.test(ch)) stack.pop(); - - index++; - } - - signature.name = signature.name.split(/\(/)[0]; - - map.set(signature.name, { - extends: signature.parent, - code, - name: signature.name, - signature: signature.definition, - }); - - code = ""; - } - - return map; - } -} - -class Grouper extends Splitter { - /** - * - * @param {array} filePaths - * @returns all the classes from all the files with the filePaths array - */ - - async classesFromFilePath(filePaths) { - let cls = ""; - const params = {}; - const names = {}; - let i = 1; - - for (let filePath of filePaths) { - const classesContent = fs.readFileSync(filePath, "utf-8"); - const map = this.classes(classesContent); - - for (let [key, { code, name, extends: parent, signature }] of map) { - if ( - /OpenScript\.Mediator/.test(parent) && - !/.*Mediator$/.test(name) - ) { - let newName = `${name}Mediator`; - let newSig = signature.replace(`${name} `, `${newName} `); - let newCode = code.replace(signature, newSig); - - code = newCode; - name = newName; - signature = newSig; - } - - if (name in names) { - let newName = `${name}${i++}`; - let newSig = signature.replace(`${name} `, `${newName} `); - let newCode = code.replace(signature, newSig); - - code = newCode; - name = newName; - signature = newSig; - } - - names[name] = true; - - cls += code + "\n"; - params[name] = true; - } - } - return { code: cls, params: Object.keys(params) }; - } - - /** - * - * @param {*} filePaths wraps all the code from a specified file path in the ojs function - */ - - async wrapClassesFromFilePath(filePaths) { - try { - let { code, params } = await this.classesFromFilePath(filePaths); - - let finalCode = `${code}\nojs(${params.join(",")});`; - - let finalContent = []; - // get prefix contents - for (const dir in config.content.prefixFiles) { - for (let path of config.content.prefixFiles[dir]) { - let content = fs.readFileSync(`${dir}/${path}`, "utf-8"); - finalContent.push(content); - } - } - - finalContent.push(finalCode); - - // get suffix contents - for (const dir in config.content.suffixFiles) { - for (let path of config.content.suffixFiles[dir]) { - let content = fs.readFileSync(`${dir}/${path}`, "utf-8"); - finalContent.push(content); - } - } - - let final = finalContent.join("\n"); - this.consolidateToFile(final); - } catch (error) { - console.error("Error printing class names:", error); - } - } - /** - * - * @param {string} consolidatedCode takes in the consolidated code and then creates a - * dotOjs file with that has all the code from all the classes wrapped in the ojs function - */ - - async consolidateToFile(consolidatedCode) { - const consolidatedFile = config.outputFile.name; - fs.writeFileSync(consolidatedFile, consolidatedCode, "utf-8"); - - // const [error, data] = await tryToCatch(minify, consolidatedFile, { - // js: { - // putout: { - // quote: "'", - // mangle: true, - // mangleClassNames: false, - // removeUnusedVariables: true, - // removeUselessSpread: true, - // }, - // }, - // }); - // if (error) return console.error(error.message); - // fs.writeFileSync(config.outputFile.minified, data, "utf-8"); - - console.log(`Wrapped code has been written to ${consolidatedFile}`); - } - - /** - * - * @param {string} dir - * @param {string} files - * @returns an array of all the paths in the given directory - */ - async getFilePaths(dir, files = []) { - const fileList = fs.readdirSync(dir); - for (const file of fileList) { - const name = `${dir}/${file}`; - if (fs.statSync(name).isDirectory()) { - this.getFilePaths(name, files); - } else { - files.push(name); - } - } - return files; - } - /** - * - * @returns all the grouped code by making use of the getFilePaths method and - * wrapClassesFromFilePath - */ - - async group() { - try { - let AllPaths = []; - for (let key in config.dir) { - let dir = config.dir[key]; - let directories = await grouper.getFilePaths(dir); - AllPaths.push(directories); - } - let paths = AllPaths; - - let configPaths = []; - for (let path of paths) { - for (let directory of path) { - configPaths.push(directory); - } - } - console.log(configPaths); - let code = await grouper.wrapClassesFromFilePath(configPaths); - return code; - } catch (error) { - console.error("Error getting all classes:", error); - } - } -} - -const grouper = new Grouper(); -grouper.group(); diff --git a/src/helpers.js b/src/helpers.js deleted file mode 100644 index 7c770d5..0000000 --- a/src/helpers.js +++ /dev/null @@ -1,291 +0,0 @@ -import { broker, EventData, route } from "./open-script"; - -export function ifElse(condition, statement1 = null, statement2 = null) { - const value = (s) => { - return typeof s === "function" ? s() : s; - }; - - return condition ? value(statement1) : value(statement2); -} - -export function either(statement1 = null, statement2 = null) { - const value = (s) => { - return typeof s === "function" ? s() : s; - }; - - return value(statement1) ?? value(statement2); -} - -/** - * Returns an object's property value if it exist or the default value - * @param {object} object object to search - * @param {string} property property to look for in object - * @param {*} def default - */ -export function notIn(object, property, def = null) { - if (property in object) return object[property]; - return def; -} - -/** - * The monetary function formats a given value as a currency using the specified currency code. - * @param {number} value - The value parameter represents the numerical value that you want to format as a - * monetary value. It can be any number, positive or negative. - * @param {string} [currency=KES] - The currency parameter is a string that represents the currency code. In the - * provided code, the default currency is set to 'KES', which represents Kenyan Shilling. - * @returns {string} a formatted string representation of the value with the specified currency symbol. - */ -export function monetary(value, currency = "KES") { - return Intl.NumberFormat("en-Us", { style: "currency", currency }).format( - value - ); -} - -export function parsePayload(ed) { - return EventData.parse(ed); -} - -/** - * Broadcasts an event from an HTML visible markup with no event payload. - * @param {string|Array} event - */ -export function sendEvent(event, _payload = null) { - broker.send(event, _payload ? _payload : payload()); -} - -/** - * Broadcasts an event from an HTML visible markup with no event payload. - * @param {string|Array} event - */ -export function fireEvent(event, payload = null) { - sendEvent(event, payload); -} - - -/** - * Checks if the user has a permission - * @param {string} permission - * @returns - */ -function can(permission) { - return uc.permissions.value[permission] == true; -} - - -function randomColor() { - let color = Math.floor(Math.random() * 16777215).toString(16); - - for (let i = color.length; i < 6; i++) { - color += "0"; - } - - return color; -} - -export function formatThousand(num) { - if (num >= 1e12) { - // Convert to trillion - return (num / 1e12).toFixed(num % 1e12 !== 0 ? 1 : 0) + "T"; - } else if (num >= 1e9) { - // Convert to billion - return (num / 1e9).toFixed(num % 1e9 !== 0 ? 1 : 0) + "B"; - } else if (num >= 1e6) { - // Convert to million - return (num / 1e6).toFixed(num % 1e6 !== 0 ? 1 : 0) + "M"; - } else if (num >= 1000) { - // Convert to thousands (k) - return (num / 1000).toFixed(num % 1000 !== 0 ? 1 : 0) + "k"; - } else { - return num.toString(); - } -} - -export function getDayOfTheWeek() { - const dayOfWeek = new Date().toLocaleString("default", { - weekday: "long", - }); - - return dayOfWeek; -} - -export function getGreetings() { - var currentHour = new Date().getHours(); - - if (currentHour >= 5 && currentHour < 12) { - return "Good morning"; - } else if (currentHour >= 12 && currentHour < 18) { - return "Good afternoon"; - } else { - return "Good evening"; - } -} - -export function isCurrentTimeInRange(startTime, endTime) { - const now = new Date(); - const currentTime = now.toTimeString().split(" ")[0]; - - function timeToSeconds(time) { - const [hours, minutes, seconds] = time.split(":").map(Number); - return hours * 3600 + minutes * 60 + seconds; - } - - const startSeconds = timeToSeconds(startTime); - const endSeconds = timeToSeconds(endTime); - const currentSeconds = timeToSeconds(currentTime); - - return currentSeconds >= startSeconds && currentSeconds <= endSeconds; -} - -export function appIsLocal() { - return /^(127\.0\.0\.1|localhost)$/.test(route.url().hostname); -} - - -export class CookieWrapper { - /** - * - * @param {string} name - * @param {string} value - * @param {int} expireAfter days - */ - static save(name, value, expireAfter = 365) { - const d = new Date(); - d.setTime(d.getTime() + expireAfter * 24 * 60 * 60 * 1000); - let expires = "expires=" + d.toUTCString(); - document.cookie = name + "=" + value + ";" + expires + ";path=/"; - } - - static get(name) { - name = name + "="; - let ca = document.cookie.split(";"); - for (let i = 0; i < ca.length; i++) { - let c = ca[i]; - while (c.charAt(0) == " ") { - c = c.substring(1); - } - if (c.indexOf(name) == 0) { - return c.substring(name.length, c.length); - } - } - - return null; - } - - static has(name, empty = false) { - let value = Cookie.get(name); - - if (value == null || (value?.length == 0 && !empty)) return false; - - return true; - } -} - -export function delay(callback, seconds) { - setTimeout(callback, seconds * 1000); -} - - -export function safeJSONParse(jsonString) { - try { - // Step 1: Unescape JSON strings to handle double-escaped characters - const unescapedJSON = jsonString.replace(/\\./g, (match) => { - switch (match) { - case '\\"': - return '"'; - case "\\n": - return "\n"; - case "\\t": - return "\t"; - // Add more escape sequences as needed - default: - return match[1]; // Remove the backslash - } - }); - - // Step 2: Parse the unescaped JSON - const parsedData = JSON.parse(unescapedJSON); - - return parsedData; - } catch (error) { - console.error("Error parsing JSON:", error); - return null; // Handle the error gracefully or throw an exception if necessary - } -} - -/** - * Checks if the element was clicked - * @description This function checks if the clicked element is the same as the provided element or if the clicked element is a child of the provided element. - * @example - * const element = document.getElementById('myElement'); - * document.addEventListener('click', (event) => { - * if (wasClicked(element, event)) { - * console.log('Element was clicked!'); - * } else { - * console.log('Element was not clicked.'); - * } - * }); - * @function wasClicked - * @param {HTMLElement} element - * @param {MouseEvent} event - * @returns - */ -export function wasClicked(element, event) { - return element == event.target || element?.contains(event.target); -} - -export function randomInt(min, max) { - min = Math.ceil(min); - max = Math.floor(max); - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -export function empty(variable) { - if (!variable) return true; - if (Array.isArray(variable) && variable.length == 0) return true; - if (typeof variable == "object" && Object.keys(variable).length == 0) - return true; - if (typeof variable == "undefined") return true; - - return false; -} - -export function insertSpaces(input) { - return input.replace(/([A-Z])/g, " $1").trim(); -} - -export function arrayFlip(obj) { - let flipped = {}; - - for (let key in obj) { - if (obj.hasOwnProperty(key)) { - flipped[obj[key]] = key; - } - } - return flipped; -} - -export function lastOf(arr) { - - if (arr.length === 0) { - return null; - } - - return arr[arr.length - 1]; -} - -/** - * - * @param {string} path - or route name - * @returns {OpenScript.Router} router - */ -export function redirect(path = null) { - if(empty(path)) return route; - - return route.to(path); -} - -export function range(start, end, increment = 1) { - output = []; - for(let i = start; i <= end; i += increment) output.push(i); - return output; -} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..692e11a --- /dev/null +++ b/src/index.js @@ -0,0 +1,174 @@ +import Runner from "./core/Runner.js"; +import Emitter from "./core/Emitter.js"; +import EventData from "./core/EventData.js"; +import State from "./core/State.js"; +import ContextProvider from "./core/ContextProvider.js"; +import Context from "./core/Context.js"; +import ProxyFactory from "./core/ProxyFactory.js"; +import AutoLoader from "./core/AutoLoader.js"; + +import Router from "./router/Router.js"; + +import Broker from "./broker/Broker.js"; +import BrokerRegistrar from "./broker/BrokerRegistrar.js"; +import Listener from "./broker/Listener.js"; + +import Mediator from "./mediator/Mediator.js"; +import MediatorManager from "./mediator/MediatorManager.js"; + +import Component from "./component/Component.js"; +import DOMReconciler from "./component/DOMReconciler.js"; +import MarkupEngine from "./component/MarkupEngine.js"; +import MarkupHandler from "./component/MarkupHandler.js"; +import { h } from "./component/h.js"; + +import Utils from "./utils/Utils.js"; +import DOM from "./utils/DOM.js"; +import { isClass, namespace } from "./utils/helpers.js"; + +// Initialize global instances +const broker = new Broker(); +const router = new Router(); +const contextProvider = new ContextProvider(); +const mediatorManager = new MediatorManager(); +const loader = new AutoLoader(); +const autoload = new AutoLoader(); + +// Global Helpers +const state = State.state; +const ojs = (...classDeclarations) => new Runner().run(...classDeclarations); +const req = (qualifiedName) => loader.req(qualifiedName); +const include = (qualifiedName) => loader.include(qualifiedName); +const v = (state, callback = (state) => state.value, ...args) => h.$anonymous(state, callback, ...args); +const context = (name) => contextProvider.context(name); +const putContext = (referenceName, qualifiedName) => contextProvider.load(referenceName, qualifiedName); +/** + * @deprecated Use putContext instead. fetchContext will be removed in future versions. + */ +const fetchContext = (referenceName, qualifiedName) => { + console.warn("fetchContext is deprecated. Please use putContext instead."); + return contextProvider.load(referenceName, qualifiedName, true); +}; +const lazyFor = Utils.lazyFor; +const each = Utils.each; +const component = (name) => h.getComponent(name); +const mediators = (names) => { + for (let qn of names) { + mediatorManager.fetchMediators(qn); + } +}; +const eData = (meta = {}, message = {}) => { + return new EventData() + .meta(meta) + .message(message) + .encode(); +}; +const payload = (message = {}, meta = {}) => eData(meta, message); +const route = router; + +// Utility Shortcuts +const ifElse = Utils.ifElse; +const coalesce = Utils.coalesce; +const dom = DOM; + +// Export everything +export { + Runner, + Emitter, + EventData, + State, + ContextProvider, + Context, + ProxyFactory, + AutoLoader, + Router, + Broker, + BrokerRegistrar, + Listener, + Mediator, + MediatorManager, + Component, + DOMReconciler, + MarkupEngine, + MarkupHandler, + h, + Utils, + DOM, + isClass, + namespace, + broker, + router, + route, + contextProvider, + mediatorManager, + loader, + autoload, + state, + ojs, + req, + include, + v, + context, + putContext, + fetchContext, + lazyFor, + each, + ifElse, + coalesce, + dom, + component, + mediators, + eData, + payload +}; + +// Default export object +export default { + Runner, + Emitter, + EventData, + State, + ContextProvider, + Context, + ProxyFactory, + AutoLoader, + Router, + Broker, + BrokerRegistrar, + Listener, + Mediator, + MediatorManager, + Component, + DOMReconciler, + MarkupEngine, + MarkupHandler, + h, + Utils, + DOM, + isClass, + namespace, + broker, + router, + route, + contextProvider, + mediatorManager, + loader, + autoload, + state, + ojs, + req, + include, + v, + context, + putContext, + fetchContext, + lazyFor, + each, + ifElse, + coalesce, + dom, + component, + mediators, + eData, + payload +}; diff --git a/src/mediator/Mediator.js b/src/mediator/Mediator.js new file mode 100644 index 0000000..0bcd5d2 --- /dev/null +++ b/src/mediator/Mediator.js @@ -0,0 +1,57 @@ +import BrokerRegistrar from "../broker/BrokerRegistrar.js"; +import { broker } from "../index.js"; + +/** + * The Mediator Class + */ +export default class Mediator { + shouldRegister() { + return true; + } + + async register() { + if (!this.shouldRegister()) return; + + let br = new BrokerRegistrar(); + br.register(this); + } + + /** + * Emits an event through the broker + * @param {string|Array} events + * @param {...string} args data to send + */ + send(events, ...args) { + broker.send(events, ...args); + return this; + } + + /** + * Emits/Broadcasts an event through the broker + * @param {string|Array} events + * @param {...any} args + */ + broadcast(events, ...args) { + return this.send(events, ...args); + } + + /** + * parses a JSON string + * `JSON.parse` + * @param {string} JSONString + * @returns + */ + parse(JSONString) { + return JSON.parse(JSONString); + } + + /** + * Stringifies a JSON Object + * `JSON.stringify` + * @param {object} object + * @returns + */ + stringify(object) { + return JSON.stringify(object); + } +} diff --git a/src/mediator/MediatorManager.js b/src/mediator/MediatorManager.js new file mode 100644 index 0000000..4af6f55 --- /dev/null +++ b/src/mediator/MediatorManager.js @@ -0,0 +1,47 @@ +import Mediator from "./Mediator.js"; +// import AutoLoader from "../core/AutoLoader.js"; // Need to find AutoLoader + +/** + * The Mediator Manager + */ +export default class MediatorManager { + static directory = "./mediators"; + static version = "1.0.0"; + + constructor() { + this.mediators = new Map(); + } + + /** + * Fetch Mediators from the Backend + * @param {string} qualifiedName + */ + async fetchMediators(qualifiedName) { + // Assuming AutoLoader is available globally or imported + // For now, commenting out AutoLoader usage until I find it + /* + let MediatorClass = await new AutoLoader( + MediatorManager.directory, + MediatorManager.version + ).include(qualifiedName); + + if (!MediatorClass) { + MediatorClass = new Map([qualifiedName, ["_", Mediator]]); + } + + for (let [k, v] of MediatorClass) { + try { + if (this.mediators.has(k)) continue; + + let mediator = new v[1](); + mediator.register(); + + this.mediators.set(k, mediator); + } catch (e) { + console.error(`Unable to load '${k}' Mediator.`, e); + } + } + */ + console.warn("MediatorManager.fetchMediators is not fully implemented yet due to missing AutoLoader."); + } +} diff --git a/src/open-script.js b/src/open-script.js deleted file mode 100644 index fdbe167..0000000 --- a/src/open-script.js +++ /dev/null @@ -1,4401 +0,0 @@ -/** - * The OpenScript Namespace - * @namespace {OpenScript} - */ -export const OpenScript = { - /** - * Used to Run Classes upon creation - */ - Runner: class Runner { - isClass(func) { - return ( - typeof func === "function" && - /^class\s/.test(Function.prototype.toString.call(func)) - ); - } - - async run(...cls) { - for (let i = 0; i < cls.length; i++) { - let c = cls[i]; - let instance; - - if (!this.isClass(c)) { - instance = new OpenScript.Component(c.name); - instance.render = c.bind(instance); - } else { - instance = new c(); - } - - if (instance instanceof OpenScript.Component) { - instance.getDeclaredListeners(); - instance.mount(); - } else if ( - instance instanceof OpenScript.Mediator || - instance instanceof OpenScript.Listener - ) { - instance.register(); - } else if (instance instanceof OpenScript.Context) { - } else { - throw Error( - `You can only pass declarations which extend OpenScript.Component, OpenScript.Mediator or OpenScript.Listener` - ); - } - } - } - }, - - /** - * OpenScript's Router Class - */ - Router: class { - /** - * - */ - constructor() { - /** - * Current Prefix - * @type {Array} - */ - this.__prefix = [""]; - - /** - * Prefix to append - * To all the runtime URL changes - * @type {string} - */ - this.__runtimePrefix = ""; - - /** - * Currently resolved string - * @type {string} - */ - this.__resolved = null; - - /** - * The routes Map - * @type {Map|string|function>} - */ - this.map = new Map(); - - this.nameMap = new Map(); - - /** - * The Params in the URL - * @type {object} - */ - this.params = {}; - - /** - * The Query String - * @type {URLSearchParams} - */ - this.qs = {}; - - /** - * Should the root element be cleared? - */ - this.reset; - - /** - * The default path - */ - this.path = ""; - - /** - * Create a route action - */ - this.RouteAction = class RouteAction { - action; - name; - - middleware = () => true; - - children = new Map(); - - run() { - return this.action(); - } - }; - - this.GroupedRoute = class GroupedRoute {}; - - this.reset = OpenScript.State.state(false); - - window.addEventListener("popstate", () => { - this.reset.value = true; - this.listen(); - }); - - /** - * Default Action - * @type {function} - */ - this.defaultAction = () => { - alert("404 File Not Found"); - }; - - this.RouteName = class RouteName { - name; - route; - - constructor(name, route) { - this.name = name; - this.route = route; - } - }; - - /** - * Allows Grouping of routes - */ - this.PrefixRoute = class PrefixRoute { - /** - * Creates a new PrefixRoute - * @param {OpenScript.Router} router - */ - constructor(router) { - /** - * Parent Router - * @type {OpenScript.Router} - */ - this.router = router; - } - - /** - * Creates a Group - * @param {function} func - * @returns {OpenScript.Router} - */ - group(func = () => {}) { - func(); - - this.router.__prefix.pop(); - - return this.router; - } - }; - } - - /** - * Sets the global runtime prefix - * to use when resolving routes - * @param {string} prefix - */ - runtimePrefix(prefix) { - this.__runtimePrefix = prefix; - } - - /** - * Sets the default path - * @param {string} path - * @returns - */ - basePath(path) { - this.path = path; - return this; - } - - /** - * Sets the default action if a route is not found - * @param {function} action - */ - default(action) { - this.defaultAction = action; - } - - isQualifiedUrl(url) { - const urlPattern = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i; - return urlPattern.test(url); - } - - /** - * Adds an action on URL path - * @param {string} path - * @param {function} action action to perform - * @param {string} name the route name - */ - on(path, action, name = null) { - let _path = `${this.path}/${this.__prefix.join( - "/" - )}/${path}`.replace(/\/{2,}/g, "/"); - - if (name) { - this.nameMap.set(name, _path); - } - - const paths = _path.split("/"); - - let key = null; - let map = this.map; - - for (const cmp of paths) { - if (cmp.length < 1) continue; - - key = /^\{\w+\}$/.test(cmp) ? "*" : cmp; - - let val = map.get(key); - if (!val) val = [cmp, new Map()]; - - map.set(key, val); - map = map.get(key)[1]; - } - - map.set("->", [true, action]); - - return this; - } - - /** - * Used to add multiple routes to the same action - * @param {Array} paths - * @param {function} action - * @param {string[]} names path names respectively - */ - orOn(paths, action, names = []) { - let i = 0; - - for (let path of paths) { - this.on(path, action, names[i] ?? null); - i++; - } - - return this; - } - - /** - * Creates a prefix for a group of routes - * @param {string} name - */ - prefix(name) { - this.__prefix.push(name); - - return new this.PrefixRoute(this); - } - - /** - * Executes the actions based on the url - */ - listen() { - let url = new URL(window.location.href); - this.params = {}; - this.__resolved = null; - - let paths = url.pathname.split("/").filter((a) => a.length); - - let map = this.map; - let r = []; - - for (const cmp of paths) { - if (cmp.length < 1) continue; - - let next = map.get(cmp); - - if (!next) { - next = map.get("*"); - if (next) this.params[next[0].replace(/[\{\}]/g, "")] = cmp; - } - - if (!next) { - console.error(`${url.pathname} was not found`); - this.defaultAction(); - return this; - } - - r.push(next[0]); - map = next[1]; - } - - this.qs = new URLSearchParams(url.search); - this.__resolved = `/${r.join("/")}`; - - broker.send("ojs:beforeRouteChange"); - - try { - let f = map.get("->")[1]; - f(); - } catch (ex) { - console.error(`${url.pathname} was not found`, ex); - this.defaultAction(); - return this; - } - - this.reset.value = false; - - broker.send("ojs:routeChanged"); - - return this; - } - - /** - * Get a route from a registered route name - * @param {string} routeName - * @returns {Router.RouteName} - */ - from(routeName) { - if (!this.nameMap.has(routeName)) { - throw Error(`Unknown Route Name: ${routeName}`); - } - - return new this.RouteName(routeName, this.nameMap.get(routeName)); - } - - /** - * Redirects to a named route - * @param {string} routeName - * @param {object} params replaces route params and adds the rest as query strings. - * @returns - */ - toName(routeName, params = {}) { - let rn = this.from(routeName); - - let p = {}; - - for (let x of rn.route.match(/\{[\w\d-_]+\}/g) ?? []) { - let k = x.substring(1, x.length - 1); - let v = params[k] ?? null; - - if (!v) { - throw Error( - `${rn.route} requires ${x} but it wasn't passed` - ); - } - - delete params[k]; - - p[x] = v; - } - - let r = rn.route; - - for (let k in p) { - r = r.replace(k, p[k]); - } - - return this.to(r, params); - } - - /** - * Change the URL path without reloading. Prioritizes route name over route path. - * @param {string} path route or route-name - * @param {object<>} qs Query strings or Route params (if using route name) - */ - to(path, qs = {}) { - if (this.isQualifiedUrl(path)) { - let link = h.a({ - href: path, - style: "display: none;", - target: "_blank", - parent: document.body, - }); - - link.click(); - link.remove(); - - return this; - } - - if (this.nameMap.has(path)) { - return this.toName(path, qs); - } - - let prefix = ""; - - if (!path.replace(/^\//, "").startsWith(this.__runtimePrefix)) { - prefix = this.__runtimePrefix; - } - - path = `${this.path}/${prefix}/${path}`.trim(); - - let paths = path.split("/"); - - path = ""; - - for (let p of paths) { - if (p.length === 0 || /^\s+$/.test(p)) continue; - - if (path.length) path += "/"; - - path += p.trim(); - } - - let s = ""; - - for (let k in qs) { - if (s.length > 0) s += "&"; - s += `${k}=${qs[k]}`; - } - - if (s.length > 0) s = `?${s}`; - - this.history().pushState( - { random: Math.random() }, - "", - `/${path}${s}` - ); - this.reset.value = true; - - return this.listen(); - } - - /** - * Gets the base URL - * @param {string} path - * @returns string - */ - baseUrl(path = "") { - return ( - new URL(window.location.href).origin + - (this.path.length > 0 ? "/" + this.path : "") + - "/" + - path - ); - } - - /** - * Redirects to a page using loading - * @param {string} to - */ - redirect(to) { - return (window.location.href = to); - } - - /** - * Refreshes the current page - */ - refresh() { - this.history().go(); - return this; - } - - /** - * Goes back to the previous route - * @returns - */ - back() { - this.history().back(); - return this; - } - - /** - * Goes forward to the next route - * @returns - */ - forward() { - this.history().forward(); - return this; - } - - /** - * Returns the Window History Object - * @returns {History} - */ - history() { - return window.history; - } - - /** - * Returns the current URL - * @returns {URL} - */ - url() { - return new URL(window.location.href); - } - - /** - * Gets the value after hash in the url - * @returns {string} - */ - hash() { - return this.url().hash.replace("#", ""); - } - - /** - * Current Route Path - * @returns string - */ - current() { - return this.url().pathname; - } - - /** - * Checks if the name|route matches the current route. - * @param {string} nameOrRoute - * @returns - */ - is(nameOrRoute) { - if (nameOrRoute == this.__resolved) return true; - - for (let [n, r] of this.nameMap) { - if (n == nameOrRoute) { - return r == this.__resolved; - } - } - - return false; - } - }, - - /** - * Event Emitter Class - */ - Emitter: class Emitter { - constructor() { - this.listeners = {}; - /** - * List of emitted events - */ - this.emitted = {}; - } - - addListener(eventName, fn) { - this.listeners[eventName] = this.listeners[eventName] || []; - this.listeners[eventName].push(fn); - return this; - } - // Attach event listener - on(eventName, fn) { - return this.addListener(eventName, fn); - } - - // Attach event handler only once. Automatically removed. - once(eventName, fn) { - this.listeners[eventName] = this.listeners[eventName] || []; - const onceWrapper = (...args) => { - fn(...args); - this.off(eventName, onceWrapper); - }; - this.listeners[eventName].push(onceWrapper); - return this; - } - - // Alias for removeListener - off(eventName, fn) { - return this.removeListener(eventName, fn); - } - - removeListener(eventName, fn) { - let lis = this.listeners[eventName]; - if (!lis) return this; - for (let i = lis.length; i > 0; i--) { - if (lis[i] === fn) { - lis.splice(i, 1); - break; - } - } - return this; - } - - // Fire the event - emit(eventName, ...args) { - this.emitted[eventName] = args; - - let fns = this.listeners[eventName]; - if (!fns) return false; - fns.forEach((f) => { - try { - f(...args); - } catch (e) { - console.error(e); - } - }); - return true; - } - - listenerCount(eventName) { - let fns = this.listeners[eventName] || []; - return fns.length; - } - - // Get raw listeners - // If the once() event has been fired, then that will not be part of - // the return array - rawListeners(eventName) { - return this.listeners[eventName]; - } - }, - - /** - * The Broker Class - */ - Broker: class Broker { - /** - * Should the events be logged as they are fired? - */ - #shouldLog = false; - - #emitOnlyRegisteredEvents = false; - - /** - * The event listeners - * event: {time:xxx, args: xxx} - */ - #logs = {}; - - /** - * The emitter - */ - #emitter = new OpenScript.Emitter(); - - constructor() { - /** - * TIME DIFFERENCE BEFORE GARBAGE - * COLLECTION - */ - this.CLEAR_LOGS_AFTER = 10000; - - /** - * TIME TO GARBAGE COLLECTION - */ - this.TIME_TO_GC = 30000; - } - - /** - * Add Event Listeners - * @param {string|Array} events - space or | separated events - * @param {function} listener - asynchronous function - */ - on(events, listener) { - if (Array.isArray(events)) { - for (let event of events) { - this.on(event, listener); - } - - return; - } - - events = this.parseEvents(events); - - for (let event of events) { - event = event.trim(); - - this.verifyEventRegistration(event); - - if (this.#logs[event]) { - let emitted = this.#logs[event]; - - for (let i = 0; i < emitted.length; i++) { - listener(...emitted[i].args); - } - } - - this.#emitter.on(event, listener); - } - } - - verifyEventRegistration(event) { - if ( - this.#emitOnlyRegisteredEvents && - !(event in this.#emitter.listeners) - ) { - throw Error( - `BrokerError: Cannot listen to or emit unregistered event: ${event}. - You can turn off event registration requirement to stop this behavior.` - ); - } - } - - /** - * - * @param {object} events ```json - * { - * event1: true, - * ns: { - * event1: true, - * subNs: { - * event:true - * } - * } - * } - * ``` - * @returns - */ - registerEvents(events) { - const dfs = (event, prefix = "", ref = {}) => { - if (typeof event === "string") { - if (event.length === 0) return; - - let name = event; - - if (prefix.length > 0) { - event = `${prefix}:${event}`; - } - - if (!(event in this.#emitter.listeners)) { - this.#emitter.listeners[event] = []; - - ref[name] = event; - } else { - throw Error( - `Cannot re-register event: ${event}. Event already registered` - ); - } - - return; - } - - const accepted = { - object: true, - boolean: true, - }; - - for (let e in event) { - if (!(typeof event[e] in accepted)) { - throw Error( - `Invalid Event declaration: ${ - prefix ? prefix + "." : "" - }${e}: ${event[e]}` - ); - } - - if (typeof event[e] === "object") { - dfs( - event[e], - `${prefix.length > 0 ? prefix + ":" : prefix}${e}`, - event[e] - ); - } else { - dfs(e, prefix, event); - } - } - - return; - }; - - dfs(events); - } - - /** - * Emits an event - * @param {string|Array} events - space or | separated events - * @param {...any} args - * @returns - */ - async send(events, ...args) { - return this.emit(events, ...args); - } - - /** - * Broadcasts an event - * @param {string|Array} events- space or | separated events - * @param {...any} args - * @returns - */ - async broadcast(events, ...args) { - return this.send(events, ...args); - } - - /** - * Emits Events - * @param {string|Array} events - * @param {...any} args - * @returns - */ - async emit(events, ...args) { - if (Array.isArray(events)) { - for (let event of events) { - this.emit(event, ...args); - } - - return; - } - - events = this.parseEvents(events); - - for (let i = 0; i < events.length; i++) { - let evt = events[i].trim(); - - this.verifyEventRegistration(evt); - - if (evt.length < 1) continue; - this.#emit(evt, ...args); - } - } - - /** - * - * @param {string} events - */ - parseEvents(events) { - if (typeof events !== "string") { - throw Error(`cannot pass events that is not a string`); - } - - if (!/[,\s\|\(\)\{\}\[\]]/.test(events)) { - return [events]; - } - - let final = []; - let ns = []; - let word = []; - - let last = ""; - let found = ""; - - for (let i = 0; i < events.length; i++) { - let ch = events[i]; - - if (ch == "{" || ch == "[" || ch == "(") { - last = ns[ns.length - 1]; - found = word.join(""); - word = []; - - if (last) { - last = `${last}:${found}`; - } else { - last = found; - } - - ns.push(last); - - continue; - } - - if (ch == "}" || ch == "]" || ch == ")") { - found = word.join(""); - word = []; - last = ns.pop(); - - if (found.length < 1) continue; - - if (last && last.length > 0) { - found = `${last}:${found}`; - } - - final.push(found); - continue; - } - - if (/[\s\|,]/.test(ch)) { - found = word.join(""); - word = []; - - if (found.length < 1) continue; - - last = ns[ns.length - 1]; - - if (last && last.length > 0) { - found = `${last}:${found}`; - } - - final.push(found); - continue; - } - - word.push(ch); - } - - found = word.join(""); - word = []; - last = ns[ns.length - 1]; - - if (found.length > 0) { - if (last && last.length > 0) { - found = `${last}:${found}`; - } - - final.push(found); - } - - return final; - } - - async #emit(event, ...args) { - const currentTime = () => new Date().getTime(); - - this.#logs[event] = this.#logs[event] ?? []; - this.#logs[event].push({ timestamp: currentTime(), args: args }); - - if (args.length == 0) { - args.push(new OpenScript.EventData().encode()); - } - - args.push(event); - - if (this.#shouldLog) { - console.trace(`fired ${event}: args: `, args); - } - - return this.#emitter.emit(event, ...args); - } - - /** - * Clear the logs - */ - clearLogs() { - for (let event in this.#logs) { - let d = new Date(); - let k = -1; - - for (let i in this.#logs[event]) { - if ( - d.getTime() - this.#logs[event][i].timestamp >= - this.TIME_TO_GC - ) { - k = i; - } - } - - if (k !== -1) { - this.#logs[event] = this.#logs[event].slice(k + 1); - } - - if (this.#logs[event].length < 1) delete this.#logs[event]; - } - } - - /** - * Do Events Garbage Collection - */ - removeStaleEvents() { - setInterval(this.clearLogs.bind(this), this.CLEAR_LOGS_AFTER); - } - - /** - * If the broker should display events as they are fired - * @param {boolean} shouldLog - */ - withLogs(shouldLog) { - this.#shouldLog = shouldLog; - } - - /** - * - * @param {boolean} requireEventsRegistration - */ - requireEventsRegistration(requireEventsRegistration = true) { - this.#emitOnlyRegisteredEvents = requireEventsRegistration; - } - }, - - /** - * Registers events on the broker - */ - BrokerRegistrar: class BrokerRegistrar { - async registerNamespace(namespace, events, obj) { - if (typeof events !== "object") { - console.error( - `Namespace has incorrect declaration syntax: '${namespace}' with value: `, - events, - `in ${obj.constructor.name}` - ); - - return; - } - - for (let event in events) { - if ( - event.startsWith("$$") || - (typeof events[event] === "object" && - !(typeof events[event] === "function")) - ) { - this.registerNamespace( - `${namespace}:${ - event.startsWith("$$") ? event.substring(2) : event - }`, - events[event], - obj - ); - } else { - let ev = event.split(/_/g).filter((a) => a.length > 0); - - for (let e of ev) { - this.registerMethod( - `${namespace}:${e}`, - events[event], - obj - ); - } - } - } - } - - async register(o) { - let obj = o; - let seen = new Set(); - - do { - for (let method of Object.getOwnPropertyNames(obj)) { - if (seen.has(method)) continue; - if (method.length < 3) continue; - if (!method.startsWith("$$")) continue; - - if (typeof obj[method] !== "function") { - await this.registerNamespace( - method.substring(2), - obj[method], - obj - ); - continue; - } - - this.registerMethod(method.substring(2), obj[method], obj); - - seen.add(method); - } - } while ((obj = Object.getPrototypeOf(obj))); - } - - async registerMethod(method, listener, object) { - let events = method.split(/_/g).filter((a) => a.length > 0); - - for (let ev of events) { - if (ev.length === 0) continue; - broker.on(ev, listener.bind(object)); - } - } - }, - - /** - * The Mediator Manager - */ - MediatorManager: class MediatorManager { - static directory = "./mediators"; - static version = "1.0.0"; - - constructor() { - this.mediators = new Map(); - } - - /** - * Fetch Mediators from the Backend - * @param {string} qualifiedName - */ - async fetchMediators(qualifiedName) { - let Mediator = await new OpenScript.AutoLoader( - MediatorManager.directory, - MediatorManager.version - ).include(qualifiedName); - - if (!Mediator) { - Mediator = new Map([qualifiedName, ["_", OpenScript.Mediator]]); - } - - for (let [k, v] of Mediator) { - try { - if (this.mediators.has(k)) continue; - - let mediator = new v[1](); - mediator.register(); - - this.mediators.set(k, mediator); - } catch (e) { - console.error(`Unable to load '${k}' Mediator.`, e); - } - } - } - }, - - /** - * The Mediator Class - */ - Mediator: class Mediator { - shouldRegister() { - return true; - } - - async register() { - if (!this.shouldRegister()) return; - - let br = new OpenScript.BrokerRegistrar(); - br.register(this); - } - - /** - * Emits an event through the broker - * @param {string|Array} events - * @param {...string} args data to send - */ - send(events, ...args) { - broker.send(events, ...args); - return this; - } - - /** - * Emits/Broadcasts an event through the broker - * @param {string|Array} events - * @param {...any} args - */ - broadcast(events, ...args) { - return this.send(events, ...args); - } - - /** - * parses a JSON string - * `JSON.parse` - * @param {string} JSONString - * @returns - */ - parse(JSONString) { - return JSON.parse(JSONString); - } - - /** - * Stringifies a JSON Object - * `JSON.stringify` - * @param {object} object - * @returns - */ - stringify(object) { - return JSON.stringify(object); - } - }, - - /** - * A Broker Listener - */ - Listener: class Listener { - /** - * Registers with the broker - */ - async register() { - let br = new OpenScript.BrokerRegistrar(); - br.register(this); - } - }, - - /** - * The Event Data class - */ - EventData: class EventData { - constructor() { - /** - * The Meta Data - */ - this._meta = {}; - - /** - * Message containing the args - */ - this._message = {}; - } - - meta(data) { - this._meta = data; - return this; - } - - message(data) { - this._message = data; - return this; - } - - /** - * Convert the Event Schema to string - * @returns {string} - */ - encode() { - return JSON.stringify(this); - } - - /** - * JSON.parse - * @param {string} str - * @returns {EventData} - */ - static decode(str) { - return JSON.parse(str); - } - /** - * Parse and Event Data - * @param {string} eventData - * @returns - */ - static parse(eventData) { - let ed = OpenScript.EventData.decode(eventData); - - if (!"_meta" in ed) ed._meta = {}; - if (!"_message" in ed) ed._message = {}; - - return { - meta: { - ...ed._meta, - has: function (key) { - return key in this; - }, - get: function (key, def = null) { - return this[key] ?? def; - }, - put: function (key, value) { - this[key] = value; - return this; - }, - remove: function (key) { - delete this[key]; - return this; - }, - getAll: function () { - return ed._meta; - }, - }, - message: { - ...ed._message, - has: function (key) { - return key in this; - }, - get: function (key, def = null) { - return this[key] ?? def; - }, - put: function (key, value) { - this[key] = value; - return this; - }, - remove: function (key) { - delete this[key]; - return this; - }, - getAll: function () { - return ed._message; - }, - }, - encode: function () { - return eData(this.meta, this.message); - }, - }; - } - }, - - DOMReconciler: class Reconciler { - /** - * @param {Node} domNode - * @param {Node} newNode - */ - replace(domNode, newNode) { - try { - return domNode.parentNode.replaceChild(newNode, domNode); - } catch (e) { - console.error(e, domNode, domNode.parentNode); - } - } - - /** - * Replaces the attributes of node1 with that of node2 - * @param {HTMLElement} node1 - * @param {HTMLElement} node2 - */ - replaceAttributes(node1, node2) { - let length1 = node1.attributes.length; - let length2 = node2.attributes.length; - - let remove = []; - let add = []; - - let mx = Math.max(length1, length2); - - for (let i = 0; i < mx; i++) { - if (i >= length1) { - let attr = node2.attributes[i]; - add.push({ name: attr.name, value: attr.value }); - continue; - } - - if (i >= length2) { - let attr = node1.attributes[i]; - remove.push(attr.name); - continue; - } - - let attr1 = node1.attributes[i]; - let attr2 = node2.attributes[i]; - - if (!node2.hasAttribute(attr1.name)) { - remove.push(attr1.name); - } else if (attr1.value != node2.getAttribute(attr1.name)) { - add.push({ - name: attr1.name, - value: node2.getAttribute(attr1.name), - }); - } - - if (attr2.value != node1.getAttribute(attr2.name)) { - add.push({ name: attr2.name, value: attr2.value }); - } - } - - mx = Math.max(remove.length, add.length); - let mem = new Set(); - - for (let i = 0; i < mx; i++) { - if (i < remove.length && !mem.has(remove[i])) { - node1.removeAttribute(remove[i]); - } - if (i < add.length) { - node1.setAttribute(add[i].name, add[i].value); - mem.add(add[i].name); - } - } - } - - /** - * - * @param {Node} node1 - * @param {Node} node2 - * @returns - */ - equal(node1, node2) { - return node1?.isEqualNode(node2) == true; - } - - getEventListeners(node) { - if (!node.__eventListeners) { - node.__eventListeners = {}; - } - return node.__eventListeners || {}; - } - - replaceEventListeners(targetNode, sourceNode) { - const events = this.getEventListeners(targetNode); - - for (const eventName in events) { - events[eventName].forEach((listener) => { - targetNode.removeListener(eventName, listener); - }); - } - - const sourceEvents = this.getEventListeners(sourceNode); - - for (const eventName in sourceEvents) { - sourceEvents[eventName].forEach((listener) => { - targetNode.addListener(eventName, listener); - }); - } - } - - replaceAddedMethods(targetNode, sourceNode) { - if (!sourceNode.__methods) { - return; - } - - targetNode.__methods = {}; - - for (let m in sourceNode.__methods) { - targetNode.__methods[m] = sourceNode.__methods[m]; - } - - return; - } - - /** - * - * @param {Node|HTMLElement} current - * @param {Node|HTMLElement} previous - currently on the DOM - */ - reconcile(current, previous) { - if (this.isText(current)) { - this.replace(previous, current); - return true; - } - - this.replaceEventListeners(previous, current); - this.replaceAddedMethods(previous, current); - - if (this.equal(current, previous)) { - return false; - } - - if (this.isElement(current) && this.isElement(previous)) { - if (current.tagName !== previous.tagName) { - this.replace(previous, current); - return true; - } - - this.replaceAttributes(previous, current); - - if (this.equal(previous, current)) { - return false; - } - - let i = 0, - j = 0; - let prevLength = previous.childNodes.length; - let curLength = current.childNodes.length; - let _pc = curLength; - - while (i < prevLength && j < curLength) { - this.reconcile( - current.childNodes[j], - previous.childNodes[i] - ); - - _pc = curLength; - curLength = current.childNodes.length; - - if (_pc === curLength) j++; - - i++; - } - - while (i < previous.childNodes.length) { - previous.childNodes[i]?.remove(); - } - - while (j < current.childNodes.length) { - previous.append(current.childNodes[j]); - } - - return true; - } else { - this.replace(previous, current); - return true; - } - } - - /** - * - * @param {Node} node - */ - isText(node) { - return node.nodeType === Node.TEXT_NODE; - } - - /** - * - * @param {Node} node - * @returns - */ - isElement(node) { - return node.nodeType === Node.ELEMENT_NODE; - } - - /** - * - * @param {object} attr1 - * @param {object} attr2 - * @returns - */ - attributesEq(attr1, attr2) { - return JSON.stringify(attr1) == JSON.stringify(attr2); - } - }, - /** - * Base Component Class - */ - Component: class Component { - /** - * Anonymous component ID - */ - static aCId = 0; - - /** - * Generate IDs for the components - */ - static uid = 0; - - /** - * Use for returning fragments - */ - static FRAGMENT = "OJS-SPECIAL-FRAGMENT"; - - constructor(name = null) { - /** - * List of events that the component emits - */ - this.EVENTS = { - rendered: "rendered", // component is visible on the dom - rerendered: "rerendered", // component was rerendered - premount: "premount", // component is ready to register - mounted: "mounted", // the component is now registered - prebind: "prebind", // the component is ready to bind - bound: "bound", // the component has bound - markupBound: "markup-bound", // a temporary markup has bound - beforeHidden: "before-hidden", - hidden: "hidden", - unmounted: "unmounted", // removed from the markup engine memory - beforeVisible: "before-visible", // before the markup is made visible - visible: "visible", // the markup is now made visible - }; - - /** - * List of all components that are listening to - * specific events - */ - this.listening = {}; - - /** - * All the states that this component is listening to - * @type {object} - */ - this.states = {}; - - /** - * List of components that this component is listening - * to. - */ - this.listeningTo = {}; - - /** - * Has the component being mounted - */ - this.mounted = false; - - /** - * Has the component bound - */ - this.bound = false; - - /** - * Has the component rendered - */ - this.rendered = false; - - /** - * Has the component rerendered - */ - this.rerendered = false; - - /** - * Is the component visible - */ - this.visible = true; - - /** - * The argument Map for rerendering on state changes - */ - this.argsMap = new Map(); - - /** - * Event Emitter for the component - */ - this.emitter = new OpenScript.Emitter(); - - this.isAnonymous = false; - - this.name = name ?? this.constructor.name; - - this.emitter.once( - this.EVENTS.rendered, - (th) => (th.rendered = true) - ); - this.on(this.EVENTS.hidden, (th) => (th.visible = false)); - this.on(this.EVENTS.rerendered, (th) => (th.rerendered = true)); - this.on(this.EVENTS.bound, (th) => (th.bound = true)); - this.on(this.EVENTS.mounted, (th) => (th.mounted = true)); - this.on(this.EVENTS.visible, (th) => (th.visible = true)); - this.getDeclaredListeners(); - - this.$$ojs = { - routeChanged: () => { - setTimeout(() => { - if (this.markup().length == 0) { - if (this.isAnonymous) { - return h.deleteComponent(this.name); - } - - this.releaseMemory(); - } - }, 1000); - }, - }; - - /** - * Compare two Nodes - */ - this.Reconciler = OpenScript.DOMReconciler; - } - - /** - * Write Clean Up Logic in this function - */ - cleanUp() {} - - /** - * Make the component's method accessible from the - * global window - * @param {string} methodName - the method name - * @param {[*]} args - arguments to pass to the method - * To pass a literal string param use '${param}' in the args. - * For example ['${this}'] this will reference the DOM element. - */ - method(name, args) { - if (!Array.isArray(args)) { - args = [args]; - } - return h.func([this, name], ...args); - } - - /** - * Get an external Component's method - * to add it to a DOM Element - * @param {string} componentMethod `Component.method` e.g. 'MainNav.notify' - * @param {[*]} args - */ - xMethod(componentMethod, args) { - let splitted = componentMethod - .trim() - .split(/\./) - .map((a) => a.trim()); - - if (splitted.length < 2) { - console.error( - `${componentMethod} has syntax error. Please use ComponentName.methodName` - ); - } - - return component(splitted[0]).method(splitted[1], args); - } - - /** - * Adds a Listening component - * @param {event} event - * @param {OpenScript.Component} component - */ - addListeningComponent(component, event) { - if (this.emitsTo(component, event)) return; - - if (!this.listening[event]) this.listening[event] = new Map(); - this.listening[event].set(component.name, component); - - component.addEmittingComponent(this, event); - } - - /** - * Adds a component that this component is listening to - * @param {string} event - * @param {OpenScript.Component} component - */ - addEmittingComponent(component, event) { - if (this.listensTo(component, event)) return; - - if (!this.listeningTo[component.name]) - this.listeningTo[component.name] = new Map(); - - this.listeningTo[component.name].set(event, component); - - component.addListeningComponent(this, event); - } - - /** - * Checks if this component is listening - * @param {string} event - * @param {OpenScript.Component} component - */ - emitsTo(component, event) { - return this.listening[event]?.has(component.name) ?? false; - } - - /** - * Checks if this component is listening to the other - * component - * @param {*} event - * @param {*} component - */ - listensTo(component, event) { - return this.listeningTo[component.name]?.has(event) ?? false; - } - - /** - * Deletes a component from the listening array - * @param {string} event - * @param {OpenScript.Component} component - */ - doNotListenTo(component, event) { - this.listeningTo[component.name]?.delete(event); - - if (this.listeningTo[component.name]?.size == 0) { - delete this.listeningTo[component.name]; - } - - if (!component.emitsTo(this, event)) return; - - component.doNotEmitTo(this, event); - } - - /** - * Stops this component from emitting to the other component - * @param {string} event - * @param {OpenScript.Component} component - * @returns - */ - doNotEmitTo(component, event) { - this.listening[event]?.delete(component.name); - - if (!component.listensTo(this, event)) return; - component.doNotListenTo(this, event); - } - - /** - * Get all Emitters declared in the component - */ - getDeclaredListeners() { - let obj = this; - let seen = new Set(); - - do { - if (!(obj instanceof OpenScript.Component)) break; - - for (let method of Object.getOwnPropertyNames(obj)) { - if (seen.has(method)) continue; - - if (typeof this[method] !== "function") continue; - if (method.length < 3) continue; - - if (!method.startsWith("$_")) continue; - - let meta = method.substring(1).split(/\$/g); - - let events = meta[0].split(/_/g); - events.shift(); - let cmpName = this.name; - - let subjects = meta.slice(1); - - if (!subjects?.length) subjects = [this.name, "on"]; - - let methods = { on: true, onAll: true }; - - let stack = []; - - for (let i = 0; i < subjects.length; i++) { - let current = subjects[i]; - stack.push(current); - - while (stack.length) { - i++; - current = subjects[i] ?? null; - - if (current && methods[current]) { - stack.push(current); - } else { - stack.push("on"); - i--; - } - - let m = stack.pop(); - let cmp = stack.pop(); - - for (let j = 0; j < events.length; j++) { - let ev = events[j]; - - if (!ev.length) continue; - - h[m](cmp, ev, (component, event, ...args) => { - try { - h - .getComponent(cmpName) - [method]?.bind( - h.getComponent(cmpName) - )(component, event, ...args); - } catch (e) { - console.error(e); - } - }); - } - } - } - - seen.add(method); - } - } while ((obj = Object.getPrototypeOf(obj))); - - const br = new OpenScript.BrokerRegistrar(); - - br.register(this); - } - /** - * Initializes the component and adds it to - * the component map of the markup engine - * @emits mounted - * @emits pre-mount - */ - async mount() { - h.component(this.name, this); - - this.claimListeners(); - this.emit(this.EVENTS.premount); - await this.bindComponent(); - this.emit(this.EVENTS.mounted); - } - - /** - * Deletes all the component's markup from the DOM - */ - unmount() { - let all = this.markup(); - - for (let elem of all) { - elem.remove(); - } - - this.releaseMemory(); - - return true; - } - - /** - * Checks if this component has - * elements on the dom and if they are - * visible - */ - checkVisibility() { - let elem = h.dom.querySelector(`ojs-${this.kebab(this.name)}`); - - if ( - elem && - elem.parentElement?.style.display !== "none" && - !this.visible - ) { - return this.show(); - } - - if ( - elem && - elem.parentElement?.style.display === "none" && - this.visible - ) { - return this.hide(); - } - - if ( - elem && - elem.style.display !== "none" && - elem.style.visibility !== "hidden" && - !this.visible - ) { - this.show(); - } - - if ( - (!elem || - elem.style.display === "none" || - elem.style.visibility === "hidden") && - this.visible - ) { - this.hide(); - } - } - - /** - * Emits an event - * @param {string} event - * @param {Array<*>} args - */ - emit(event, args = []) { - this.emitter.emit(event, this, event, ...args); - } - - /** - * Binds this component to the elements on the dom. - * @emits pre-bind - * @emits markup-bound - * @emits bound - */ - async bindComponent() { - this.emit(this.EVENTS.prebind); - - let all = h.dom.querySelectorAll( - `ojs-${this.kebab(this.name)}-tmp--` - ); - - if (all.length == 0 && !this.bindCalled) { - this.bindCalled = true; - setTimeout(this.bindComponent.bind(this), 500); - return; - } - - for (let elem of all) { - let hId = elem.getAttribute("ojs-key"); - - let args = [...h.compArgs.get(hId)]; - h.compArgs.delete(hId); - - this.wrap(...args, { parent: elem, replaceParent: true }); - - this.emit(this.EVENTS.markupBound, [elem, args]); - } - - this.emit(this.EVENTS.bound); - - return true; - } - - /** - * Converts camel case to kebab case - * @param {string} name - */ - kebab(name) { - let newName = ""; - - for (const c of name) { - if (c.toLocaleUpperCase() === c && newName.length > 1) - newName += "-"; - newName += c.toLocaleLowerCase(); - } - - return newName; - } - - /** - * Return all the current DOM elements for this component - * From the parent. - * @param {HTMLElement | null} parent - * @returns - */ - markup(parent = null) { - if (!parent) parent = h.dom; - - return parent.querySelectorAll(`ojs-${this.kebab(this.name)}`); - } - - /** - * Hides all the markup of this component - * @emits before-hidden - * @emits hidden - * @returns {bool} - */ - hide() { - this.emit(this.EVENTS.beforeHidden); - - let all = this.markup(); - - for (let elem of all) { - elem.style.display = "none"; - } - - this.emit(this.EVENTS.hidden); - - return true; - } - - /** - * Remove style-display-none from all this component's markup - * @emits before-visible - * @emits visible - * @returns bool - */ - show() { - this.emit(this.EVENTS.beforeVisible); - - let all = this.markup(); - - for (let elem of all) { - elem.style.display = ""; - } - - this.emit(this.EVENTS.visible); - - return true; - } - - /** - * Ensure that the action will get called - * even if the event was emitted previous - * @param {string} event - * @param {...function} listeners - */ - onAll(event, ...listeners) { - // check if we have previously emitted this event - listeners.forEach((a) => { - if (event in this.emitter.emitted) - a(...this.emitter.emitted[event]); - - this.emitter.on(event, a); - }); - } - - /** - * Add Event Listeners to that component - * @param {string} event - * @param {...function} listeners - */ - on(event, ...listeners) { - // check if we have previously emitted this event - listeners.forEach((a) => { - if (Array.isArray(a)) { - a.forEach((f) => this.emitter.on(event, f)); - return; - } - - this.emitter.on(event, a); - }); - } - - /** - * Gets all the listeners for itself and adds them to itself - */ - claimListeners() { - if (!h.eventsMap.has(this.name)) return; - - let events = h.eventsMap.get(this.name); - - for (let event in events) { - events[event].forEach((listener) => { - let func = listener.function; - - if (listener.type === "all") this.onAll(event, func); - else this.on(event, func); - }); - } - - h.eventsMap.delete(this.name); - } - - releaseMemory() { - this.cleanUp(); - - for (let event in this.listening) { - for (let [_name, component] of this.listening[event]) { - component.doNotListenTo(this, event); - } - } - - for (let id in this.states) { - this.states[id]?.off(this.name); - delete this.states[id]; - } - - this.argsMap = new Map(); - this.listeningTo = {}; - this.listening = {}; - - if (this.isAnonymous) { - this.emitter.listeners = {}; - this.emitter.emitted = {}; - } - } - - /** - * Renders the Element and returns an HTML Element - * @param {...any} args - * @returns {DocumentFragment|HTMLElement|String|Array} - */ - render(...args) { - return h.ojs(...args); - } - - /** - * Finds the parent in the argument list - * @param {Array<*>} args - * @returns - */ - getParentAndListen(args) { - let final = { - index: -1, - parent: null, - states: [], - resetParent: false, - replaceParent: false, - firstOfParent: false, - }; - - for (let i in args) { - if ( - args[i] instanceof OpenScript.State || - (args[i] && - typeof args[i].$__name__ !== "undefined" && - args[i].$__name__ == "OpenScript.State") - ) { - args[i].listener(this); - this.states[args[i].$__id__] = args[i]; - final.states.push(args[i].$__id__); - } else if ( - !( - args[i] instanceof DocumentFragment || - args[i] instanceof HTMLElement - ) && - args[i] && - !Array.isArray(args[i]) && - typeof args[i] === "object" && - args[i].parent - ) { - if (args[i].parent) { - final.index = i; - final.parent = args[i].parent; - } - - const keys = [ - "resetParent", - "replaceParent", - "firstOfParent", - ]; - - for (let reserved of keys) { - if (args[i][reserved]) { - final[reserved] = args[i][reserved]; - delete args[i][reserved]; - } - } - - delete args[i].parent; - } - } - - return final; - } - - /** - * Gets the value of object - * @param {any|OpenScript.State} object - * @returns - */ - getValue(object) { - if (object instanceof OpenScript.State) return object.value; - return object; - } - - /** - * Wraps the rendered content - * @emits re-rendered - * @param {...any} args - * @returns - */ - wrap(...args) { - const lastArg = args[args.length - 1]; - let { - index, - parent, - resetParent, - states, - replaceParent, - firstOfParent, - } = this.getParentAndListen(args); - - // check if the render was called due to a state change - if (lastArg && lastArg["called-by-state-change"]) { - let state = lastArg.self; - - delete args[index]; - - let current = - h.dom.querySelectorAll( - `ojs-${this.kebab(this.name)}[s-${state.$__id__}="${ - state.$__id__ - }"]` - ) ?? []; - - let reconciler = new this.Reconciler(); - - current.forEach((e) => { - if (!this.visible) e.style.display = "none"; - else e.style.display = ""; - - // e.textContent = ""; - - let arg = this.argsMap.get(e.getAttribute("uuid")); - let attr = { - // parent: e, - component: this, - event: this.EVENTS.rerendered, - eventParams: [{ markup: e, component: this }], - }; - - let shouldReconcile = true; - - if (e.childNodes.length === 0) { - attr.parent = e; - shouldReconcile = false; - } - - let markup = this.render(...arg, attr); - - if (shouldReconcile) { - if (Array.isArray(markup)) { - let newParent = e.cloneNode(); - newParent.append(...markup); - reconciler.reconcile(newParent, e); - } else { - reconciler.reconcile(markup, e.childNodes[0]); - } - } - }); - - return; - } - - let event = this.EVENTS.rendered; - - if ( - parent && - (this.getValue(resetParent) || this.getValue(replaceParent)) - ) { - if (!this.markup().length) this.argsMap.clear(); - else { - let all = this.markup(parent); - - all.forEach((elem) => - this.argsMap.delete(elem.getAttribute("uuid")) - ); - } - - if (this.argsMap.size) event = this.EVENTS.rerendered; - } - - let uuid = `${OpenScript.Component.uid++}-${new Date().getTime()}`; - - this.argsMap.set(uuid, args ?? []); - - let attr = { - uuid, - resetParent, - replaceParent, - firstOfParent, - class: "__ojs-c-class__", - }; - - if (parent) attr.parent = parent; - - states.forEach((id) => { - attr[`s-${id}`] = id; - }); - - let markup = this.render(...args, { withCAttr: true }); - - if ( - markup.tagName == OpenScript.Component.FRAGMENT && - markup.childNodes.length > 0 - ) { - let children = markup.childNodes; - - return children.length > 1 ? children : children[0]; - } - - if (!this.visible) attr.style = "display: none;"; - - let cAttributes = {}; - - if (markup instanceof HTMLElement) { - cAttributes = JSON.parse( - markup?.getAttribute("c-attr") ?? "{}" - ); - markup.setAttribute("c-attr", ""); - } - - attr = { - ...attr, - component: this, - event, - eventParams: [{ markup, component: this }], - }; - - return h[`ojs-${this.kebab(this.name)}`](attr, markup, cAttributes); - } - - isHtml(markup) { - return markup instanceof HTMLElement; - } - - /** - * Returns a mounted anonymous component's name. - */ - static anonymous() { - let id = OpenScript.Component.aCId++; - - let Cls = class extends OpenScript.Component { - constructor() { - super(); - this.name = `anonym-${id}`; - this.isAnonymous = true; - } - - /** - * Render function takes a state - * @param {OpenScript.State} state - * @param {Function} callback that returns the value to - * put in the markup - * @returns - */ - render(state, callback, ...args) { - let markup = callback(state, ...args); - return h[`ojs-wrapper`](markup, ...args); - } - }; - - let c = new Cls(); - - c.mount(); - - return c.name; - } - }, - - /** - * Creates a Proxy - */ - ProxyFactory: class { - /** - * Makes a Proxy - * @param {class} Target - * @param {class} Handler - * @returns - */ - static make(Target, Handler) { - return new Proxy(new Target(), new Handler()); - } - }, - - /** - * The base Context Provider - */ - ContextProvider: class { - /** - * The directory in which the Context - * files are located - */ - static directory; - - /** - * The version number for the network request to - * get updated files - */ - static version; - - constructor() { - /** - * The Global Context - */ - this.globalContext = {}; - - /** - * Context mapping - */ - this.map = new Map(); - - /** - * Adds a Context Path to the Map - * @param {string|Array} referenceName - * @param {string} qualifiedName The Context File path, ignoring the context directory itself. - * @param {boolean} fetch Should the file be fetched from the backend - * @param {boolean} load Should this context be loaded automatically - */ - this.put = async (referenceName, qualifiedName, fetch = false) => { - - if (!Array.isArray(referenceName)) - referenceName = [referenceName]; - - let c = this.map.get(referenceName[0]); - - let shouldFetch = false; - - if (!c || (c && !c.__fromNetwork__ && fetch)) - shouldFetch = true; - - if (shouldFetch) { - let Context = fetch - ? await new OpenScript.AutoLoader( - OpenScript.ContextProvider.directory, - OpenScript.ContextProvider.version - ).include(qualifiedName) - : null; - - if (!Context) { - Context = new Map([ - qualifiedName, - ["_", OpenScript.Context], - ]); - } - - let counter = 0; - - for (let [k, v] of Context) { - try { - let cxt = new v[1](); - - /** - * Update States that should be updated - */ - let key = - referenceName[counter] ?? cxt.__contextName__; - - if (shouldFetch) cxt.reconcile(this.map, key); - - this.map.set(key, cxt); - } catch (e) { - console.error( - `Unable to load '${referenceName}' context because it already exists in the window. Please ensure that you are loading your contexts before your components`, - e - ); - } - - counter++; - } - } else { - console.warn( - `[${referenceName}] context already exists. If you have multiple contexts in the file in ${qualifiedName}, then you can use context('[contextName]Context') or the aliases you give them to access them.` - ); - } - - return this.context(referenceName); - }; - } - - /** - * Gets the Context with the given name. - * @note The name must be in the provider's map - * @param {string} name - */ - context(name) { - return this.map.get(name); - } - - /** - * Asynchronously loads a context - * @param {string|Array} referenceName - * @param {string} qualifiedName - * @param {boolean} fetch - */ - load(referenceName, qualifiedName, fetch = false) { - if (!Array.isArray(referenceName)) referenceName = [referenceName]; - - for (let name of referenceName) { - let c = this.map.get(name); - - if (!c) { - this.map.set(name, new OpenScript.Context()); - } - } - - this.put(referenceName, qualifiedName, fetch); - - return referenceName.length === 1 - ? this.map.get(referenceName[0]) - : this.map; - } - - /** - * Refreshes the whole context - */ - refresh() { - this.map.clear; - } - }, - - /** - * The Base Context Class for OpenScript - */ - Context: class { - constructor() { - /** - * Let us know if this context was loaded from the network - */ - this.__fromNetwork__ = false; - - /** - * Keeps special keys - */ - this.$__specialKeys__ = new Map(); - this.__contextName__ = this.constructor.name + "Context"; - this.__referenceName__ = this.__contextName__; - - for (const key in this) { - this.$__specialKeys__.set(key, true); - } - } - - /** - * Puts a value in the context - * @param {string} name - * @param {*} value - */ - put(name, value = {}) { - this[name] = value; - } - - /** - * Get a value from the context - * @param {string} name - * @returns - */ - get(name) { - return this[name]; - } - - /** - * Reconciles all states in the temporary context with the loaded context - * including additional data - * @param {Map} map - * @param {string} referenceName - */ - reconcile(map, referenceName) { - let cxt = map.get(referenceName); - - if (!cxt) return true; - - for (let key in cxt) { - if (this.$__specialKeys__.has(key)) continue; - - let v = cxt[key]; - - if (v instanceof OpenScript.State && !v.$__changed__) { - v.value = this[key]?.value ?? v.value; - } - - this[key] = v; - } - - this.__fromNetwork__ = true; - - return true; - } - - /** - * Ensures a property exist - * @param {string} name - * @param {*} def - * @returns {OpenScript.Context|any} - */ - has(name, def = state({})) { - if (!this[name]) this[name] = def; - return this[name]; - } - - /** - * Sets all the initial values in state - * so that upon load, they can cause DOM re-rendering - * @param {object} obj - */ - states(obj = {}) { - for (let k in obj) { - if (this[k]) continue; - - this[k] = state(obj[k]); - } - } - }, - - /** - * The main State class - */ - State: class { - /** - * The count of the number of states in the program - */ - static count = 0; - - static VALUE_CHANGED = "value-changed"; - - constructor() { - /** - * The value of the state - */ - this.value; - - /** - * ID of this state - */ - this.$__id__; - - /** - * Has this state changed - */ - this.$__changed__ = false; - - this.$__name__ = "OpenScript.State"; - - this.$__CALLBACK_ID__ = 0; - - /** - * Tells the component to rerender - */ - this.$__signature__ = { - "called-by-state-change": true, - self: this, - }; - - this.$__listeners__ = new Map(); - } - - /** - * Add a component that listens to this state - * @param {OpenScript.Component|Function} listener - * @returns - */ - listener(listener) { - if (listener instanceof OpenScript.Component) { - this.$__listeners__.set(listener.name, listener); - return listener.name; - } else { - let id = this.$__CALLBACK_ID__++; - this.$__listeners__.set(`callback-${id}`, listener); - return `callback-${id}`; - } - } - - /** - * Adds a listener that is automatically removed once the event is fired - * @param {OpenScript.Component|Function} listener - * @returns - */ - once(listener) { - let id = null; - let onceWrapper = null; - - if (listener instanceof OpenScript.Component) { - id = listener.name; - - onceWrapper = { - name: id, - - wrap: ((...args) => { - this.off(id); - return listener.wrap(...args); - }).bind(this), - }; - } else { - id = `callback-${this.$__CALLBACK_ID__++}`; - onceWrapper = ((...args) => { - this.off(id); - return listener(...args); - }).bind(this); - } - - this.$__listeners__.set(id, onceWrapper); - - return id; - } - - /** - * Removes a Component - * @param {string} id - * @returns - */ - off(id) { - return this.$__listeners__.delete(id); - } - - /** - * Fires on state change - * @param {...any} args - * @returns - */ - async fire(...args) { - for (let [k, listener] of this.$__listeners__) { - if (/^callback-\d+$/.test(k)) { - listener(this, ...args); - } else { - listener.wrap(...args, this.$__signature__); - } - } - - return this; - } - - *[Symbol.iterator]() { - if (typeof this.value !== "object") { - yield this.value; - } else { - for (let k in this.value) { - yield this.value[k]; - } - } - } - - toString() { - return `${this.value}`; - } - - /** - * Creates a new State - * @param {any} value - * @returns {OpenScript.State} - */ - static state(v = null) { - return OpenScript.ProxyFactory.make( - class extends OpenScript.State { - constructor() { - super(); - this.value = v; - this.$__id__ = OpenScript.State.count++; - } - - push = (...args) => { - if (!Array.isArray(this.value)) { - throw Error( - "OpenScript.State.Exception: Cannot execute push on a state whose value is not an array" - ); - } - - this.value.push(...args); - this.$__changed__ = true; - - this.fire(); - }; - }, - class { - set(target, prop, value) { - if (prop === "value") { - let current = target.value; - let nVal = value; - - if (typeof nVal !== "object" && current === nVal) - return true; - - Reflect.set(...arguments); - - target.$__changed__ = true; - - target.fire(); - - return true; - } else if ( - !( - prop in - { - $__listeners__: true, - $__signature__: true, - $__CALLBACK_ID__: true, - } - ) && - target.value[prop] !== value - ) { - target.value[prop] = value; - target.$__changed__ = true; - - target.fire(); - - return true; - } - - return Reflect.set(...arguments); - } - - get(target, prop, receiver) { - if ( - prop === "length" && - typeof target.value === "object" - ) { - return Object.keys(target.value).length; - } - - if ( - typeof prop !== "symbol" && - /\d+/.test(prop) && - Array.isArray(target.value) - ) { - return target.value[prop]; - } - - if ( - !target[prop] && - target.value && - typeof target.value === "object" && - target.value[prop] - ) - return target.value[prop]; - - return Reflect.get(...arguments); - } - - deleteProperty(target, prop) { - if (typeof target.value !== "object") return false; - - if (Array.isArray(target.value)) { - target.value = target.value.filter( - (v, i) => i != prop - ); - } else { - delete target.value[prop]; - } - - target.$__changed__ = true; - target.fire(); - - return true; - } - } - ); - } - }, - - /** - * Various Utility Functions - */ - Utils: class { - /** - * Runs a foreach on an array - * @param {Iterable} array - * @param {Function} callback - */ - static each = (array, callback = (v, index) => v) => { - let output = []; - if (Array.isArray(array)) { - array.forEach((v, i) => output.push(callback(v, i))); - } else { - for (let k in array) output.push(callback(array[k], k)); - } - return output; - }; - - /** - * Iterates over array elements using setTimeout - * @param {Iterable} array - * @param {Function} callback - */ - static lazyFor = (array, callback = (v) => v) => { - let index = 0; - - if (array.length < 1) return; - - const iterate = () => { - callback(array[index]); - index++; - - if (index < array.length) return setTimeout(iterate, 0); - }; - - setTimeout(iterate, 0); - }; - - /** - * Converts kebab case to camel case - * @param {string} name - * @param {boolean} upperFirst - */ - static camel(name, upperFirst = false) { - let _name = ""; - let upper = upperFirst; - - for (const c of name) { - if (c === "-") { - upper = true; - continue; - } - if (upper) { - _name += c.toUpperCase(); - upper = false; - } else { - _name += c; - } - } - - return _name; - } - - /** - * Converts camel case to kebab case - * @param {string} name - */ - static kebab(name) { - let newName = ""; - - for (const c of name) { - if (c.toLocaleUpperCase() === c && newName.length > 1) - newName += "-"; - newName += c.toLocaleLowerCase(); - } - - return newName; - } - }, - - /** - * Base Markup Engine Class - */ - MarkupEngine: class { - /** - * The IDs for components on the DOM awaiting - * rendering - */ - static ID = 0; - - constructor() { - /** - * Keeps the components - * @type {Map} - */ - this.compMap = new Map(); - - /** - * Keeps the components arguments - * @type {Map} - */ - this.compArgs = new Map(); - - /** - * Keeps a temporary component-events map - * @type {Map>} - */ - this.eventsMap = new Map(); - - this.reconciler = new OpenScript.DOMReconciler(); - - /** - * References the DOM object - */ - this.dom = window.document; - - /** - * - * @param {string} name component name - * @param {OpenScript.Component} component OpenScript component for rendering. - * - * - * @return {HTMLElement|Array} - */ - this.component = (name, component) => { - if (!(typeof name === "string")) { - throw Error( - `OpenScript.MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` - ); - } - - if (!(component instanceof OpenScript.Component)) { - throw new Error( - `OpenScript.MarkupEngine.Exception: The component for ${name} must be an OpenScript.Component component. ${component.constructor.name} given` - ); - } - - this.compMap.set(name, component); - }; - - /** - * Deletes the component from the Markup Engine Map. - * @emits unmount - * Removes an already registered company - * @param {string} name - * @param {boolean} withMarkup remove the markup of this component - * as well. - * @returns {boolean} - */ - this.deleteComponent = (name, withMarkup = true) => { - if (!this.has(name)) { - return false; - } - - if (withMarkup) this.getComponent(name).unmount(); - - this.getComponent(name).emit("unmount"); - - return this.compMap.delete(name); - }; - - /** - * Checks if a component is registered with the - * markup engine. - * @param {string} name - * @returns - */ - this.has = (name) => { - return this.compMap.has(name); - }; - - /** - * Checks if a component is registered - * @param {string} name - * @param {string} method method name - * @returns - */ - this.isRegistered = (name, method = "access") => { - if (this.has(name)) return true; - - console.warn( - `OpenScript.MarkupEngine.Warn: Trying to ${method} an unregistered component {${name}}. Please ensure that the component is registered by using h.has(componentName)` - ); - - return false; - }; - - this.reconcile = (domNode, newNode) => { - this.reconciler.reconcile(newNode, domNode); - }; - - /** - * Removes all a component's markup - * from the DOM - * @param {string} name - */ - this.hide = (name) => { - if (!this.isRegistered(name, "hide")) return false; - - const c = this.getComponent(name); - c.hide(); - - return true; - }; - - /** - * make all the component visible - * @param {string} name component name - * @returns - */ - this.show = (name) => { - if (!this.isRegistered(name, "show")) return false; - - const c = this.getComponent(name); - c.show(); - - return true; - }; - - this.modify = (element) => { - element.__eventListeners = element.__eventListeners ?? {}; - - element.addListener = function (event, listener) { - this.__eventListeners[event] = - this.__eventListeners[event] ?? []; - this.__eventListeners[event].push(listener); - this.addEventListener(event, listener); - }; - - element.removeListener = function (event, listener) { - this.__eventListeners[event] = this.__eventListeners[ - event - ]?.filter((x) => x !== listener); - - this.removeEventListener(event, listener); - }; - - element.getEventListeners = function () { - return this.__eventListeners; - }; - - if (!element.__methods) { - element.__methods = {}; - } - - element.methods = function () { - let methods = {}; - - for (let m in this.__methods) { - methods[m] = this.__methods[m].bind(this); - } - - return methods; - }; - }; - - this.fromString = (string, outerElement = "div", ...args) => { - let elem = h[outerElement](...args); - elem.innerHTML = string; - return elem; - }; - - /** - * handles the DOM element creation - * @param {string} name - * @param {...any} args - */ - this.handle = (name, ...args) => { - if (/^[_\$]+$/.test(name)) { - name = OpenScript.Component.FRAGMENT.toLowerCase(); - } - - let isSvg = false; - - if (/^\$\w+$/.test(name)) { - name = name.substring(1); - isSvg = true; - } - - /** - * If this is a component, return it - */ - - if (this.compMap.has(name)) { - return this.compMap.get(name).wrap(...args); - } - - let component; - let event = ""; - let eventParams = []; - - const isComponentName = (tag) => { - return /^ojs-.*$/.test(tag); - }; - - /** - * - * @param {string} tag - */ - const getComponentName = (tag) => { - let name = tag - .toLowerCase() - .replace(/^ojs-/, "") - .replace(/-tmp--$/, ""); - - return ojsUtils.camel(name, true); - }; - - /** - * @type {DocumentFragment|HTMLElement} - */ - let parent = null; - - let emptyParent = false; - let replaceParent = false; - let prependToParent = false; - let rootFrag = new DocumentFragment(); - - const isUpperCase = (string) => /^[A-Z]*$/.test(string); - let isComponent = isUpperCase(name[0]); - - /** - * @type {HTMLElement} - */ - let root = null; - - let componentAttribute = {}; - let withCAttr = false; - - /** - * When dealing with a component - * save the argument for async rendering - */ - if (isComponent) { - root = this.dom.createElement( - `ojs-${ojsUtils.kebab(name)}-tmp--` - ); - - let id = `ojs-${ojsUtils.kebab(name)}-${OpenScript - .MarkupEngine.ID++}`; - - root.setAttribute("ojs-key", id); - root.setAttribute("class", "__ojs-c-class__"); - - this.compArgs.set(id, args); - } else { - root = isSvg - ? this.dom.createElementNS( - "http://www.w3.org/2000/svg", - name - ) - : this.dom.createElement(name); - } - - this.modify(root); - - let parseAttr = (obj) => { - for (let k in obj) { - let v = obj[k]; - - if (v instanceof OpenScript.State) { - v = v.value; - } - - if (k === "parent" && v instanceof HTMLElement) { - parent = v; - continue; - } - - if (k === "resetParent" && typeof v === "boolean") { - emptyParent = v; - continue; - } - - if (k === "firstOfParent" && typeof v === "boolean") { - prependToParent = v; - continue; - } - - if (k === "event" && typeof v === "string") { - event = v; - continue; - } - - if (k === "replaceParent" && typeof v === "boolean") { - replaceParent = v; - continue; - } - - if (k === "eventParams") { - if (!Array.isArray(v)) v = [v]; - eventParams = v; - continue; - } - - if ( - k === "component" && - v instanceof OpenScript.Component - ) { - component = v; - continue; - } - - if (k === "c_attr") { - componentAttribute = v; - continue; - } - - if (k.length && k[0] === "$") { - componentAttribute[k.substring(1)] = v; - continue; - } - - if (k === "withCAttr") { - withCAttr = true; - continue; - } - - if (k === "listeners") { - if (typeof v !== "object") { - throw TypeError( - `The value of 'listeners' should be an object. but found ${typeof v}` - ); - } - - for (let evt in v) { - let listener = v[evt]; - - if (Array.isArray(listener)) { - listener.forEach((l) => - root.addListener(evt, l) - ); - } else { - root.addListener(evt, listener); - } - } - - continue; - } - - if (k === "methods") { - if (typeof v !== "object") { - throw TypeError( - `The value of 'methods' attribute should be an object. but found ${typeof v}` - ); - } - - for (let method in v) { - let func = v[method]; - root.__methods[method] = func; - } - - continue; - } - - let val = `${v}`; - if (Array.isArray(v)) val = `${v.join(" ")}`; - - k = k.replace(/_/g, "-"); - - if (k === "class" || k === "Class") { - let cls = root.getAttribute(k) ?? ""; - val = cls + (cls.length > 0 ? " " : "") + `${val}`; - } - - try { - root.setAttribute(k, val); - } catch (e) { - console.error( - `OpenScript.MarkupEngine.ParseAttribute.Exception: `, - e, - `. Attributes resulting in the error: `, - obj - ); - throw Error(e); - } - } - }; - - const parse = (arg, isComp) => { - if ( - arg instanceof DocumentFragment || - arg instanceof HTMLElement || - arg instanceof SVGElement || - arg instanceof OpenScript.State - ) { - if (isComp) return true; - - if (arg instanceof OpenScript.State) { - typeof arg.value === "string" && - rootFrag.append(document.createTextNode(arg)); - } else { - rootFrag.append(arg); - } - - return true; - } - - if (typeof arg === "object") { - parseAttr(arg); - return true; - } - - if (typeof arg !== "undefined") { - rootFrag.append(arg); - return true; - } - - return false; - }; - - for (let arg of args) { - if (isComponent && parent) break; - - // if (arg instanceof OpenScript.State) continue; - - if ( - Array.isArray(arg) || - arg instanceof HTMLCollection || - arg instanceof NodeList - ) { - if (isComponent) continue; - - arg.forEach((e) => { - if (e) parse(e, isComponent); - }); - - continue; - } - - if (parse(arg, isComponent)) continue; - - if (isComponent) continue; - - let v = this.toElement(arg); - if (typeof v !== "undefined") rootFrag.append(v); - } - - root.append(rootFrag); - - if (withCAttr) { - let atr = JSON.stringify(componentAttribute); - if (atr) root.setAttribute("c-attr", atr); - } - - root.toString = function () { - return this.outerHTML; - }; - - if (parent) { - if (emptyParent) { - parent.textContent = ""; - } - - if (replaceParent) { - this.reconcile(parent, root); - } else if (prependToParent) { - parent.prepend(root); - } else { - parent.append(root); - } - } - - if (component) { - component.emit(event, eventParams); - - let sc = root.querySelectorAll(".__ojs-c-class__"); - sc.forEach((c) => { - if (!isComponentName(c.tagName.toLowerCase())) return; - let cmpName = getComponentName(c.tagName); - h.getComponent(cmpName)?.emit(event, eventParams); - }); - } - - return root; - }; - - /** - * Executes a function that returns an - * HTMLElement and adds that element to the overall markup. - * @param {function} f - This function should return an HTMLElement or a string or an Array of either - * @returns {HTMLElement|string|Array} - */ - this.call = (f = () => h["ojs-group"]()) => { - return f(); - }; - - /** - * Allows you to add functions to HTML elements - * @param {Array} ComponentAndMethod name of the method - * @param {...any} args arguments to pass to the method - * @returns - */ - this.func = (name, ...args) => { - let method = null; - let component = null; - - if (!Array.isArray(name)) { - method = name; - return `${method}(${this._escape(args)})`; - } - - method = name[1]; - component = name[0]; - - return `component('${ - component.name - }')['${method}'](${this._escape(args)})`; - }; - - /** - * - * adds quotes to string arguments - * and serializes objects for - * param passing - * @note To escape adding quotes use ${string} - */ - this._escape = (args) => { - let final = []; - - for (let e of args) { - if (typeof e === "number") final.push(e); - else if (typeof e === "boolean") final.push(e); - else if (typeof e === "string") { - if (e.length && e.substring(0, 2) === "${") { - let length = - e[e.length - 1] === "}" - ? e.length - 1 - : e.length; - final.push(e.substring(2, length)); - } else final.push(`'${e}'`); - } else if (typeof e === "object") - final.push(JSON.stringify(e)); - } - - return final; - }; - - this.__addToEventsMap = (component, event, listeners) => { - if (!this.eventsMap.has(component)) { - this.eventsMap.set(component, {}); - this.eventsMap.get(component)[event] = listeners; - return; - } - - if (!this.eventsMap.get(component)[event]) { - this.eventsMap.get(component)[event] = []; - } - - this.eventsMap.get(component)[event].push(...listeners); - }; - - /** - * Adds an event listener to a component - * @param {string|Array} component component name - * @param {string} event event name - * @param {...function} listeners listeners - */ - this.on = (component, event, ...listeners) => { - let components = component; - - if (!Array.isArray(component)) components = [component]; - - for (let component of components) { - if (/\./.test(component)) { - let tmp = component.split(".").filter((e) => e); - component = tmp[0]; - listeners.push(event); - event = tmp[1]; - } - - if (this.has(component)) { - this.getComponent(component).on(event, ...listeners); - - continue; - } - - listeners.forEach((f, i) => { - listeners[i] = { type: "after", function: f }; - }); - - this.__addToEventsMap(component, event, listeners); - } - }; - - /** - * Add events listeners to a component that will - * execute even after the event has been emitted - * @param {string|Array} component - * @param {string} event - * @param {...function} listeners - */ - this.onAll = (component, event, ...listeners) => { - let components = component; - - if (!Array.isArray(component)) components = [component]; - - for (let component of components) { - if (/\./.test(component)) { - let tmp = component.split(".").filter((e) => e); - component = tmp[0]; - listeners.push(event); - event = tmp[1]; - } - - if (this.has(component)) { - this.getComponent(component).onAll(event, ...listeners); - continue; - } - - listeners.forEach((f, i) => { - listeners[i] = { type: "all", function: f }; - }); - - this.__addToEventsMap(component, event, listeners); - } - }; - - /** - * Gets the event emitter of a component - * @param {string} component component name - * @returns - */ - this.emitter = (component) => { - return this.compMap.get(component)?.emitter; - }; - - /** - * Gets a component and returns it - * @param {string} name - * @returns {OpenScript.Component|null} - */ - this.getComponent = (name) => { - return this.compMap.get(name); - }; - - /** - * Creates an anonymous component - * around a state - * @param {OpenScript.State} state - * @param {Array} attribute attribute path - * @returns - */ - this.$anonymous = ( - state, - callback = (state) => state.value, - ...args - ) => { - return h[OpenScript.Component.anonymous()]( - state, - callback, - ...args - ); - }; - - /** - * Converts a value to HTML element; - * @param {string|HTMLElement} value - */ - this.toElement = (value) => { - return value; - }; - } - }, - - /** - * Handler for the OpenScript.MarkupEngine - */ - MarkupHandler: class { - static proxyInstance = null; - - constructor() { - let keys = Object.keys(new OpenScript.MarkupEngine()); - /** - * The reserved properties of the Markup engine - */ - this.reserved = new Map(); - keys.forEach((e) => this.reserved.set(e, true)); - } - - get(target, prop, receiver) { - if (this.reserved.has(prop)) { - return target[prop]; - } - - return (...args) => target.handle(prop, ...args); - } - - /** - * For Documentation, we return a proxy of Markup Engine - * @returns {OpenScript.MarkupEngine} - */ - static proxy() { - if (!OpenScript.MarkupHandler.proxyInstance) - OpenScript.MarkupHandler.proxyInstance = new Proxy( - new OpenScript.MarkupEngine(), - new OpenScript.MarkupHandler() - ); - - return OpenScript.MarkupHandler.proxyInstance; - } - }, - - /** - * AutoLoads a class from a file - */ - AutoLoader: class ClassLoader { - /** - * Keeps track of the files that have been loaded - */ - static history = new Map(); - - /** - * - * @param {string} dir Directory from which the file should be loaded - * @param {string} extension the extension of the file .js by default - */ - constructor(dir = ".", version = "1.0.0") { - /** - * The Directory or URL in which all JS files are located - */ - this.dir = "."; - - /** - * The extension of the files - */ - this.extension = ".js"; - - /** - * The version of the files. It will be appended as ?v=1.0 for example - * This enable fresh reloading if necessary - */ - this.version = "1.0.0"; - - this.dir = dir; - this.version = version; - } - - /** - * Changes . to forward slashes - * @param {string|Array} text - * @returns - */ - normalize(text) { - if (text instanceof Array) { - return text.join("/"); - } - return text.replace(/\./g, "/"); - } - - /** - * Changes / to . - * @param {string|Array} text - * @returns - */ - dot(text) { - if (text instanceof Array) { - return text.join("."); - } - return text.replace(/\//g, "."); - } - - /** - * Splits a file into smaller strings - * based on the class in that file - */ - Splitter = class Splitter { - /** - * Gets the class Signature - * @param {string} content - * @param {int} start - * @param {object<>} signature {name: string, signature: string, start: number, end: number} - */ - classSignature(content, start) { - const signature = { - name: "", - definition: "", - start: -1, - end: -1, - parent: null, - }; - - let startAt = start; - - let output = []; - let tmp = ""; - - let pushTmp = (index) => { - if (tmp.length === 0) return; - - if (output.length === 0) startAt = index; - - output.push(tmp); - tmp = ""; - }; - - for (let i = start; i < content.length; i++) { - let ch = content[i]; - - if (/[\s\r\t\n]/.test(ch)) { - pushTmp(i); - - continue; - } - - if (/\{/.test(ch)) { - pushTmp(i); - signature.end = i; - - break; - } - - tmp += ch; - } - - signature.start = startAt; - - if (output.length && output[0] !== "class") { - let temp = []; - temp[0] = output[0]; - temp[1] = output.splice(1).join(" "); - output = temp; - } - - if (output.length % 2 !== 0) - throw Error( - `Invalid Class File. Could not parse \`${content}\` from index ${start} because it doesn't have the proper syntax. ${content.substring( - start - )}` - ); - - if (output.length > 2) { - signature.parent = output[3]; - } - - signature.name = output[1]; - signature.definition = output.join(" "); - - return signature; - } - - /** - * Splits the content of the file by - * class - * @param {string} content file content - * @return {Map} class map - */ - classes(content) { - content = content.trim(); - - const stack = []; - const map = new Map(); - const qMap = new Map([ - [`'`, true], - [`"`, true], - ["`", true], - ]); - - let index = 0; - let code = ""; - - while (index < content.length) { - let signature = this.classSignature(content, index); - index = signature.end; - - let ch = content[index]; - stack.push(ch); - - code += signature.definition + " "; - code += ch; - - let text = []; - - index++; - - while (stack.length && index < content.length) { - ch = content[index]; - code += ch; - - if (qMap.has(ch)) { - text.push(ch); - index++; - - while (text.length && index < content.length) { - ch = content[index]; - code += ch; - - let last = text.length - 1; - - if (qMap.has(ch) && ch === text[last]) { - text.pop(); - } else if ( - ch === "\n" && - (text[last] === '"' || text[last] === "'") - ) { - text.pop(); - } - - index++; - } - continue; - } - if (/\{/.test(ch)) stack.push(ch); - if (/\}/.test(ch)) stack.pop(); - - index++; - } - - signature.name = signature.name.split(/\(/)[0]; - - map.set(signature.name, { - extends: signature.parent, - code, - name: signature.name, - signature: signature.definition, - }); - - code = ""; - } - - return map; - } - }; - - /** - * - * @param {string} fileName script name without the .js. - */ - async req(fileName) { - if (!/^[\w\._-]+$/.test(fileName)) - throw Error( - `OJS-INVALID-FILE: '${fileName}' is an invalid file name` - ); - - let names = fileName.split(/\./); - - if (OpenScript.AutoLoader.history.has(`${this.dir}.${fileName}`)) - return OpenScript.AutoLoader.history.get( - `${this.dir}.${fileName}` - ); - - let response = await fetch( - `${this.dir}/${this.normalize(fileName)}${this.extension}?v=${ - this.version - }`, - { - headers: { "x-powered-by": "OpenScriptJs" }, - } - ); - - let classes = await response.text(); - let content = classes; - - let classMap = new Map(); - let codeMap = new Map(); - let basePrefix = ""; - - try { - let url = new URL(this.dir); - basePrefix = this.dot(url.pathname); - } catch (e) { - basePrefix = this.dot(this.dir); - } - - let prefixArray = [ - ...basePrefix.split(/\./g).filter((v) => v.length), - ...names, - ]; - - let prefix = prefixArray.join("."); - if (prefix.length > 0 && !/^\s+$/.test(prefix)) prefix += "."; - - let splitter = new this.Splitter(); - - classes = splitter.classes(content); - - for (let [k, v] of classes) { - let key = prefix + k; - classMap.set(key, [k, v.code]); - } - - for (let [k, arr] of classMap) { - let parent = classes.get(arr[0]).extends; - - if (parent) { - let original = parent; - - if (!/\./g.test(parent)) parent = prefix + parent; - - if (!this.exists(parent)) { - if (!classMap.has(parent)) { - await this.req(parent); - } else { - let pCode = classMap.get(parent); - - prefixArray.push(pCode[0]); - - let code = await this.setFile( - prefixArray, - Function(`return (${pCode[1]})`)() - ); - - prefixArray.pop(); - - codeMap.set(parent, [pCode[0], code]); - } - } else { - let signature = classes.get(arr[0]).signature; - - let replacement = signature.replace(original, parent); - - let c = arr[1].replace(signature, replacement); - arr[1] = c; - } - } - - if (!this.exists(k)) { - prefixArray.push(arr[0]); - - let code = await this.setFile( - prefixArray, - Function(`return (${arr[1]})`)() - ); - - prefixArray.pop(); - - codeMap.set(k, [arr[0], code]); - } - } - - OpenScript.AutoLoader.history.set( - `${this.dir}.${fileName}`, - codeMap - ); - - return codeMap; - } - - async include(fileName) { - try { - return await this.req(fileName); - } catch (e) {} - - return null; - } - - /** - * Adds a class file to the window - * @param {Array} names - */ - async setFile(names, content) { - OpenScript.namespace(names[0]); - - let obj = window; - let final = names.slice(0, names.length - 1); - - for (const n of final) { - if (!obj[n]) obj[n] = {}; - obj = obj[n]; - } - - obj[names[names.length - 1]] = content; - - // Init the component if it is a - // component - - if (content.prototype instanceof OpenScript.Component) { - let c = new content(); - - if (h.has(c.name)) return; - c.getDeclaredListeners(); - await c.mount(); - } - // if component is function, register it. - else if (typeof content === "function" && !this.isClass(content)) { - let c = new OpenScript.Component(content.name); - - if (h.has(c.name)) return; - - c.render = content.bind(c); - c.getDeclaredListeners(); - await c.mount(); - } - - return content; - } - - isClass(func) { - return ( - typeof func === "function" && - /^class\s/.test(Function.prototype.toString.call(func)) - ); - } - - /** - * Checks if an object exists in the window - * @param {string} qualifiedName - */ - exists = (qualifiedName) => { - let names = qualifiedName.split(/\./); - let obj = window[names[0]]; - - for (let i = 1; i < names.length; i++) { - if (!obj) return false; - obj = obj[names[i]]; - } - - if (!obj) return false; - - return true; - }; - }, - - /** - * Adds a new Namespace to the window - * @param {string} name - */ - namespace: (name) => { - if (!window[name]) window[name] = {}; - return window[name]; - }, - - /** - * Initializes the OpenScript - */ - Initializer: class Initializer { - constructor( - configs = { - directories: { - components: "./components", - contexts: "./contexts", - mediators: "./mediators", - }, - - version: "1.0.0", - } - ) { - /** - * Wrapper to write OJS codes in - * @param {...class} classDeclarations - */ - this.ojs = (...classDeclarations) => { - return new OpenScript.Runner().run(...classDeclarations); - }; - - /** - * Automatically loads in class files - */ - this.loader = new OpenScript.AutoLoader(); - - /** - * Used to Import any File - */ - this.autoload = new OpenScript.AutoLoader(); - - /** - * Create a namespace if it doesn't exists and returns it. - */ - this.namespace = OpenScript.namespace; - - /** - * Creates a new State Object - */ - this.state = (value) => OpenScript.State.state(value); - - /** - * The Utility Class - */ - this.Utils = OpenScript.Utils; - - /** - * Creates an anonymous component around a state - * @param {OpenScript.State} state - * @param {Function} callback the function that returns - * the value to put in the anonymous markup created - * @param {...} args - * @returns - */ - this.v = (state, callback = (state) => state.value, ...args) => - h.$anonymous(state, callback, ...args); - /** - * The markup engine for OpenScript.Js - */ - this.h = OpenScript.MarkupHandler.proxy(); - - /** - * Open Script Context Provider - */ - this.ContextProvider = OpenScript.ContextProvider; - - /** - * The Event Emitter Class - */ - this.Emitter = OpenScript.Emitter; - - /** - * The Router class - */ - this.Router = OpenScript.Router; - - /** - * The mediator manager - */ - this.mediatorManager = new OpenScript.MediatorManager(); - - /** - * The router object - */ - this.route = new OpenScript.Router(); - - this.loader.dir = configs.directories.components; - this.loader.version = configs.version; - - OpenScript.ContextProvider.directory = configs.directories.contexts; - - OpenScript.ContextProvider.version = configs.version; - - this.contextProvider = this.createContextProvider(); - - /** - * - * @param {string} name - * @returns {OpenScript.Context} - */ - this.context = (name) => this.contextProvider.context(name); - - OpenScript.MediatorManager.directory = - configs.directories.mediators; - OpenScript.MediatorManager.version = configs.version; - - /** - * The Broker Object - */ - this.broker = new OpenScript.Broker(); - - this.broker.registerEvents({ - ojs: { routeChanged: true, beforeRouteChange: true }, - }); - - /** - * Loads a File into the window namespace. Throws an - * exception - * @param {string} qualifiedName `Namespace.SubsNamespace.Name` the file to load. Note that Namespaces represents folders. - * @returns {class|object|Function} - * @throws Error if the file is not found - */ - this.req = async (qualifiedName) => { - return await this.loader.req(qualifiedName); - }; - - /** - * Loads a file into the Window Namespace - * @param {string} qualifiedName `Namespace.SubNamespace.Name` the file to include - * @returns {class|object|Function} - */ - this.include = async (qualifiedName) => { - return await this.loader.include(qualifiedName); - }; - - /** - * Iterates over the values of an array using set timeout. - */ - this.lazyFor = OpenScript.Utils.lazyFor; - - /** - * Iterates over each elements - * in the array - */ - this.each = OpenScript.Utils.each; - - /** - * Adds a context without loading it from the network - * @param {string} referenceName - * @param {string} qualifiedName e.g. 'Blog.Context' - * @returns - */ - this.putContext = (referenceName, qualifiedName) => { - return this.contextProvider.load(referenceName, qualifiedName); - }; - - /** - * Fetch a context asynchronously over the network and reconciles it. - * @param {string} referenceName - * @param {string} qualifiedName - * @returns - */ - this.fetchContext = (referenceName, qualifiedName) => { - return this.contextProvider.load( - referenceName, - qualifiedName, - true - ); - }; - - /** - * Gets a component - * @returns {OpenScript.Component} - */ - this.component = (name) => h.getComponent(name); - - /** - * Loads mediators - * @param {Array} names - */ - this.mediators = async (names) => { - for (let qn of names) { - this.mediatorManager.fetchMediators(qn); - } - }; - - /** - * The Mediator Manager Class - */ - this.MediatorManager = OpenScript.MediatorManager; - - /** - * The Event Data Class - */ - this.EventData = OpenScript.EventData; - - /** - * Creates an event data - * @param {object} meta - * @param {object} message - * @returns {string} encoded EventData - */ - this.eData = (meta = {}, message = {}) => { - return new OpenScript.EventData() - .meta(meta) - .message(message) - .encode(); - }; - - /** - * Creates an event payload - * @param {object} message - * @param {object} meta - * @returns {string} encoded data - */ - this.payload = (message = {}, meta = {}) => { - return this.eData(meta, message); - }; - } - - /** - * @returns {OpenScript.ContextProvider} - */ - createContextProvider() { - return OpenScript.ProxyFactory.make( - OpenScript.ContextProvider, - class { - set(target, prop, receiver) { - throw new Error( - "You cannot Set any Property on the ContextProvider" - ); - } - } - ); - } - }, -}; - -export const { - /** - * Used to register immediate components/mediators/listeners without - * loading them from a file. - */ - ojs, - - /** - * The function for autoloading components or files in general @throws exception - */ - req, - - /** - * The function for including a file without exceptions - */ - include, - - /** - * Function for creating an initial namespace in the window - */ - namespace, - - /** - * The markup engine - */ - h, - - /** - * The wrapper for anonymous components - */ - v, - - /** - * The context provider for initializing contexts and putting them in the window - */ - contextProvider, - - /** - * The Context Provider class - */ - ContextProvider, - - /** - * The underlying Autoloader for loading components - */ - loader, - - /** - * Gets a context from the window - */ - context, - - /** - * Creates a state object - */ - state, - - /** - * The Event Emitter Class - */ - Emitter, - - /** - * Lazy For-loop - */ - lazyFor, - - /** - * Asynchronously loads a context - */ - putContext, - - /** - * Fetch a Context from the network - */ - fetchContext, - - /** - * Iterates using the each function - */ - each, - - /** - * The router class - */ - Router, - - /** - * The router object - */ - route, - - /** - * Used to Autoload Files - */ - autoload, - - /** - * The OJS utility class - */ - Utils, - - /** - * Gets a Component - */ - component, - - /** - * The Mediator Manager - */ - mediatorManager, - - /** - * The Mediator Manager - */ - MediatorManager, - /** - * Fetch Mediators - */ - mediators, - - /** - * The Broker Object - */ - broker, - - /** - * The Event Data Class - */ - EventData, - - /** - * Creates an event data object - */ - eData, - - /** - * Creates an event payload - */ - payload, -} = new OpenScript.Initializer(); - -export const OJS = OpenScript; diff --git a/src/router/Router.js b/src/router/Router.js new file mode 100644 index 0000000..2b2732d --- /dev/null +++ b/src/router/Router.js @@ -0,0 +1,493 @@ +import { h } from "../component/h.js"; // Assuming h is here +import { broker } from "../index.js"; // Assuming broker is exported from index +import State from "../core/State.js"; // Assuming State is in core + +/** + * OpenScript's Router Class + */ +export default class Router { + /** + * + */ + constructor() { + /** + * Current Prefix + * @type {Array} + */ + this.__prefix = [""]; + + /** + * Prefix to append + * To all the runtime URL changes + * @type {string} + */ + this.__runtimePrefix = ""; + + /** + * Currently resolved string + * @type {string} + */ + this.__resolved = null; + + /** + * The routes Map + * @type {Map|string|function>} + */ + this.map = new Map(); + + this.nameMap = new Map(); + + /** + * The Params in the URL + * @type {object} + */ + this.params = {}; + + /** + * The Query String + * @type {URLSearchParams} + */ + this.qs = {}; + + /** + * Should the root element be cleared? + */ + this.reset; + + /** + * The default path + */ + this.path = ""; + + /** + * Create a route action + */ + this.RouteAction = class RouteAction { + action; + name; + + middleware = () => true; + + children = new Map(); + + run() { + return this.action(); + } + }; + + this.GroupedRoute = class GroupedRoute {}; + + this.reset = State.state(false); + + window.addEventListener("popstate", () => { + this.reset.value = true; + this.listen(); + }); + + /** + * Default Action + * @type {function} + */ + this.defaultAction = () => { + alert("404 File Not Found"); + }; + + this.RouteName = class RouteName { + name; + route; + + constructor(name, route) { + this.name = name; + this.route = route; + } + }; + + /** + * Allows Grouping of routes + */ + this.PrefixRoute = class PrefixRoute { + /** + * Creates a new PrefixRoute + * @param {Router} router + */ + constructor(router) { + /** + * Parent Router + * @type {Router} + */ + this.router = router; + } + + /** + * Creates a Group + * @param {function} func + * @returns {Router} + */ + group(func = () => {}) { + func(); + + this.router.__prefix.pop(); + + return this.router; + } + }; + } + + /** + * Sets the global runtime prefix + * to use when resolving routes + * @param {string} prefix + */ + runtimePrefix(prefix) { + this.__runtimePrefix = prefix; + } + + /** + * Sets the default path + * @param {string} path + * @returns + */ + basePath(path) { + this.path = path; + return this; + } + + /** + * Sets the default action if a route is not found + * @param {function} action + */ + default(action) { + this.defaultAction = action; + } + + isQualifiedUrl(url) { + const urlPattern = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i; + return urlPattern.test(url); + } + + /** + * Adds an action on URL path + * @param {string} path + * @param {function} action action to perform + * @param {string} name the route name + */ + on(path, action, name = null) { + let _path = `${this.path}/${this.__prefix.join( + "/" + )}/${path}`.replace(/\/{2,}/g, "/"); + + if (name) { + this.nameMap.set(name, _path); + } + + const paths = _path.split("/"); + + let key = null; + let map = this.map; + + for (const cmp of paths) { + if (cmp.length < 1) continue; + + key = /^\{\w+\}$/.test(cmp) ? "*" : cmp; + + let val = map.get(key); + if (!val) val = [cmp, new Map()]; + + map.set(key, val); + map = map.get(key)[1]; + } + + map.set("->", [true, action]); + + return this; + } + + /** + * Used to add multiple routes to the same action + * @param {Array} paths + * @param {function} action + * @param {string[]} names path names respectively + */ + orOn(paths, action, names = []) { + let i = 0; + + for (let path of paths) { + this.on(path, action, names[i] ?? null); + i++; + } + + return this; + } + + /** + * Creates a prefix for a group of routes + * @param {string} name + */ + prefix(name) { + this.__prefix.push(name); + + return new this.PrefixRoute(this); + } + + /** + * Executes the actions based on the url + */ + listen() { + let url = new URL(window.location.href); + this.params = {}; + this.__resolved = null; + + let paths = url.pathname.split("/").filter((a) => a.length); + + let map = this.map; + let r = []; + + for (const cmp of paths) { + if (cmp.length < 1) continue; + + let next = map.get(cmp); + + if (!next) { + next = map.get("*"); + if (next) this.params[next[0].replace(/[\{\}]/g, "")] = cmp; + } + + if (!next) { + console.error(`${url.pathname} was not found`); + this.defaultAction(); + return this; + } + + r.push(next[0]); + map = next[1]; + } + + this.qs = new URLSearchParams(url.search); + this.__resolved = `/${r.join("/")}`; + + broker.send("ojs:beforeRouteChange"); + + try { + let f = map.get("->")[1]; + f(); + } catch (ex) { + console.error(`${url.pathname} was not found`, ex); + this.defaultAction(); + return this; + } + + this.reset.value = false; + + broker.send("ojs:routeChanged"); + + return this; + } + + /** + * Get a route from a registered route name + * @param {string} routeName + * @returns {Router.RouteName} + */ + from(routeName) { + if (!this.nameMap.has(routeName)) { + throw Error(`Unknown Route Name: ${routeName}`); + } + + return new this.RouteName(routeName, this.nameMap.get(routeName)); + } + + /** + * Redirects to a named route + * @param {string} routeName + * @param {object} params replaces route params and adds the rest as query strings. + * @returns + */ + toName(routeName, params = {}) { + let rn = this.from(routeName); + + let p = {}; + + for (let x of rn.route.match(/\{[\w\d-_]+\}/g) ?? []) { + let k = x.substring(1, x.length - 1); + let v = params[k] ?? null; + + if (!v) { + throw Error( + `${rn.route} requires ${x} but it wasn't passed` + ); + } + + delete params[k]; + + p[x] = v; + } + + let r = rn.route; + + for (let k in p) { + r = r.replace(k, p[k]); + } + + return this.to(r, params); + } + + /** + * Change the URL path without reloading. Prioritizes route name over route path. + * @param {string} path route or route-name + * @param {object<>} qs Query strings or Route params (if using route name) + */ + to(path, qs = {}) { + if (this.isQualifiedUrl(path)) { + let link = h.a({ + href: path, + style: "display: none;", + target: "_blank", + parent: document.body, + }); + + link.click(); + link.remove(); + + return this; + } + + if (this.nameMap.has(path)) { + return this.toName(path, qs); + } + + let prefix = ""; + + if (!path.replace(/^\//, "").startsWith(this.__runtimePrefix)) { + prefix = this.__runtimePrefix; + } + + path = `${this.path}/${prefix}/${path}`.trim(); + + let paths = path.split("/"); + + path = ""; + + for (let p of paths) { + if (p.length === 0 || /^\s+$/.test(p)) continue; + + if (path.length) path += "/"; + + path += p.trim(); + } + + let s = ""; + + for (let k in qs) { + if (s.length > 0) s += "&"; + s += `${k}=${qs[k]}`; + } + + if (s.length > 0) s = `?${s}`; + + this.history().pushState( + { random: Math.random() }, + "", + `/${path}${s}` + ); + this.reset.value = true; + + return this.listen(); + } + + /** + * Gets the base URL + * @param {string} path + * @returns string + */ + baseUrl(path = "") { + return ( + new URL(window.location.href).origin + + (this.path.length > 0 ? "/" + this.path : "") + + "/" + + path + ); + } + + /** + * Redirects to a page using loading + * @param {string} to + */ + redirect(to) { + return (window.location.href = to); + } + + /** + * Refreshes the current page + */ + refresh() { + this.history().go(); + return this; + } + + /** + * Goes back to the previous route + * @returns + */ + back() { + this.history().back(); + return this; + } + + /** + * Goes forward to the next route + * @returns + */ + forward() { + this.history().forward(); + return this; + } + + /** + * Returns the Window History Object + * @returns {History} + */ + history() { + return window.history; + } + + /** + * Returns the current URL + * @returns {URL} + */ + url() { + return new URL(window.location.href); + } + + /** + * Gets the value after hash in the url + * @returns {string} + */ + hash() { + return this.url().hash.replace("#", ""); + } + + /** + * Current Route Path + * @returns string + */ + current() { + return this.url().pathname; + } + + /** + * Checks if the name|route matches the current route. + * @param {string} nameOrRoute + * @returns + */ + is(nameOrRoute) { + if (nameOrRoute == this.__resolved) return true; + + for (let [n, r] of this.nameMap) { + if (n == nameOrRoute) { + return r == this.__resolved; + } + } + + return false; + } +} diff --git a/src/utils/DOM.js b/src/utils/DOM.js new file mode 100644 index 0000000..a8fcaa9 --- /dev/null +++ b/src/utils/DOM.js @@ -0,0 +1,173 @@ +/** + * DOM Manipulation Utilities + */ +export default class DOM { + /** + * Gets a single element + * @param {string} selector css selector + * @param {HTMLElement} parent defaults to document + */ + static get(selector, parent = document) { + return parent.querySelector(selector); + } + + /** + * Gets all the elements + * @param {string} selector css selector + * @param {HTMLElement} parent defaults to document + * @returns {NodeList} + */ + static all(selector, parent = document) { + return parent.querySelectorAll(selector); + } + + /** + * Gets the first element from the selected node list + * @param {string} selector css selector + * @param {HTMLElement} parent defaults to document + */ + static first(selector, parent = document) { + const list = this.all(selector, parent); + return list.length === 0 ? null : list[0]; + } + + /** + * Gets the last element from the select node list + * @param {string} selector css selector + * @param {HTMLElement} parent defaults to document + */ + static last(selector, parent = document) { + const list = this.all(selector, parent); + return list.length === 0 ? null : list[list.length - 1]; + } + + /** + * Get element at a position + * @param {string} selector css selector + * @param {number} position + * @param {HTMLElement} parent defaults to document + * @returns + */ + static at(selector, position, parent = document) { + const list = this.all(selector, parent); + return list.length === 0 ? null : list[position]; + } + + /** + * Creates an HTML element + * @param {string} elementType + * @returns {HTMLElement} + */ + static create(elementType) { + return document.createElement(elementType); + } + + /** + * Puts an inner html in an element + * @param {string} innerHTML + * @param {HTMLElement} element + * @param {boolean} append append to current html? + */ + static put(innerHTML, element, append = false) { + if (append) { + element.innerHTML += innerHTML; + return; + } + + element.innerHTML = innerHTML; + } + + /** + * Get element by ID + * @param {string} id + * @param {HTMLElement} parent defaults to document + * @returns {HTMLElement|null} + */ + static id(id, parent = document) { + return parent.getElementById(`${id}`); + } + + /** + * Get elements by Class Name + * @param {string} className + * @param {HTMLElement} parent defaults to document + * @returns {NodeList} + */ + static byClass(className, parent = document) { + return this.all(`.${className}`, parent); + } + + /** + * Sets innerHTML to empty string + * @param {HTMLElement} element + */ + static clear(element) { + if (!element) return; + element.innerHTML = ""; + } + + /** + * Checks if the element has no innerHTML or value + * @param {HTMLElement} element + */ + static isEmpty(element) { + if (element?.value) return element.value.length < 1; + return /^[\t\r\n\s]*$/g.test(element?.innerHTML); + } + + /** + * Disables an element + * @param {HTMLElement} element + */ + static disable(element) { + element?.setAttribute("disabled", "true"); + } + + /** + * Enables an element + * @param {HTMLElement} element + */ + static enable(element) { + element?.removeAttribute("disabled"); + } + + /** + * Centers an absolutely positioned element (y) inside another (x), + * regardless of where x is in the DOM. + * @param {HTMLElement} container - The container element + * @param {HTMLElement} element - The element to center + * @param {boolean} useOffset - Use offsetTop/Left or getBoundingClientRect + * @param {number} adjustLeft - Adjustment for left position + * @param {number} adjustTop - Adjustment for top position + */ + static centerInside( + container, + element, + useOffset = true, + adjustLeft = 0, + adjustTop = 0 + ) { + if (!container || !element) { + console.warn("Both container and element must be provided"); + return; + } + + let top = 0; + let left = 0; + + if (useOffset) { + top = container.offsetTop + (container.offsetHeight / 2); + left = container.offsetLeft + (container.offsetWidth / 2); + } else { + const rect = container.getBoundingClientRect(); + top = rect.top + rect.height / 2; + left = rect.left + rect.width / 2; + } + + if (adjustLeft) left += adjustLeft; + if (adjustTop) top += adjustTop; + + element.style.top = `${top}px`; + element.style.left = `${left}px`; + } +} diff --git a/src/utils/Utils.js b/src/utils/Utils.js new file mode 100644 index 0000000..e17bbb1 --- /dev/null +++ b/src/utils/Utils.js @@ -0,0 +1,264 @@ +import EventData from "../core/EventData.js"; + +/** + * Various Utility Functions + */ +export default class Utils { + /** + * Runs a foreach on an array + * @param {Iterable} array + * @param {Function} callback + */ + static each = (array, callback = (v, index) => v) => { + let output = []; + if (Array.isArray(array)) { + array.forEach((v, i) => output.push(callback(v, i))); + } else { + for (let k in array) output.push(callback(array[k], k)); + } + return output; + }; + + /** + * Iterates over array elements using setTimeout + * @param {Iterable} array + * @param {Function} callback + */ + static lazyFor = (array, callback = (v) => v) => { + let index = 0; + + if (array.length < 1) return; + + const iterate = () => { + callback(array[index]); + index++; + + if (index < array.length) return setTimeout(iterate, 0); + }; + + setTimeout(iterate, 0); + }; + + /** + * Converts kebab case to camel case + * @param {string} name + * @param {boolean} upperFirst + */ + static camel(name, upperFirst = false) { + let _name = ""; + let upper = upperFirst; + + for (const c of name) { + if (c === "-") { + upper = true; + continue; + } + if (upper) { + _name += c.toUpperCase(); + upper = false; + } else { + _name += c; + } + } + + return _name; + } + + /** + * Converts camel case to kebab case + * @param {string} name + */ + static kebab(name) { + let newName = ""; + + for (const c of name) { + if (c.toLocaleUpperCase() === c && newName.length > 1) + newName += "-"; + newName += c.toLocaleLowerCase(); + } + + return newName; + } + + /** + * Evaluates a condition and returns one of two values. + * If the values are functions, they are executed. + * @param {boolean} condition + * @param {any|Function} trueValue + * @param {any|Function} falseValue + */ + static ifElse(condition, trueValue = null, falseValue = null) { + const value = (s) => { + return typeof s === "function" ? s() : s; + }; + + return condition ? value(trueValue) : value(falseValue); + } + + /** + * Returns the first non-null/undefined value. + * If the values are functions, they are executed. + * @param {any|Function} value1 + * @param {any|Function} value2 + */ + static coalesce(value1 = null, value2 = null) { + const value = (s) => { + return typeof s === "function" ? s() : s; + }; + + return value(value1) ?? value(value2); + } + + /** + * Checks if a variable is empty + * @param {any} variable + */ + static isEmpty(variable) { + if (!variable) return true; + if (Array.isArray(variable) && variable.length == 0) return true; + if (typeof variable == "object" && Object.keys(variable).length == 0) + return true; + if (typeof variable == "undefined") return true; + if (typeof variable == "string" && variable.length == 0) return true; + + return false; + } + + /** + * Formats a number as currency + * @param {number} value + * @param {string} currency + * @param {string} locale + */ + static formatCurrency(value, currency = "KES", locale = "en-US") { + return Intl.NumberFormat(locale, { style: "currency", currency }).format(value); + } + + /** + * Deep equality check + * @param {any} a + * @param {any} b + */ + static deepEqual(a, b) { + if (a === b) return true; + + if ( + typeof a !== "object" || + typeof b !== "object" || + a == null || + b == null + ) { + return false; + } + + if (Array.isArray(a) !== Array.isArray(b)) return false; + + if (Array.isArray(a)) { + if (a.length !== b.length) return false; + return a.every((item, i) => Utils.deepEqual(item, b[i])); + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + + return keysA.every((key) => Utils.deepEqual(a[key], b[key])); + } + + /** + * Generates a range of numbers + * @param {number} start + * @param {number} end + * @param {number} increment + */ + static range(start, end, increment = 1) { + const output = []; + for (let i = start; i <= end; i += increment) output.push(i); + return output; + } + + /** + * Truncates a string + * @param {string} str + * @param {number} length + */ + static truncate(str, length) { + if (str.length <= length) return str; + + if (length <= 3) return str.slice(0, length); + + const side = Math.floor((length - 3) / 2); + const start = str.slice(0, side); + const end = str.slice(str.length - (length - side - 3)); + + return start + "..." + end; + } + + /** + * Formats bytes to human readable string + * @param {number} bytes + * @param {number} decimals + */ + static formatBytes(bytes, decimals = 2) { + if (bytes === 0) return "0 Bytes"; + + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return ( + parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i] + ); + } + + /** + * Generates a random color hex string + */ + static randomColor() { + let color = Math.floor(Math.random() * 16777215).toString(16); + + for (let i = color.length; i < 6; i++) { + color += "0"; + } + + return "#" + color; + } + + /** + * Generates a random integer + * @param {number} min + * @param {number} max + */ + static randomInt(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + /** + * Delays execution + * @param {Function} callback + * @param {number} seconds + */ + static delay(callback, seconds) { + setTimeout(callback, seconds * 1000); + } + + /** + * Deep copy of an object + * @param {object} object + */ + static deepCopy(object) { + return JSON.parse(JSON.stringify(object)); + } + + /** + * Parses an event payload + * @param {string} eventData + * @returns {object} + */ + static parsePayload(eventData) { + return EventData.parse(eventData); + } +} diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 0000000..44ebe9d --- /dev/null +++ b/src/utils/helpers.js @@ -0,0 +1,20 @@ +/** + * Checks if a function is a class + * @param {function} func + * @returns {boolean} + */ +export function isClass(func) { + return ( + typeof func === "function" && + /^class\s/.test(Function.prototype.toString.call(func)) + ); +} + +/** + * Adds a new Namespace to the window + * @param {string} name + */ +export function namespace(name) { + if (!window[name]) window[name] = {}; + return window[name]; +} diff --git a/styles/tailwind.css b/styles/tailwind.css new file mode 100644 index 0000000..c03bb31 --- /dev/null +++ b/styles/tailwind.css @@ -0,0 +1,31 @@ +/* Base Tailwind directives */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom OpenScript styles */ +@layer components { + /* Component-specific utilities */ + .os-card { + @apply bg-white rounded-lg shadow-md p-4; + } + + .os-btn { + @apply px-4 py-2 rounded font-medium transition-colors; + } + + .os-btn-primary { + @apply bg-os-primary text-white hover:bg-blue-600; + } + + .os-input { + @apply border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-os-primary; + } +} + +@layer utilities { + /* Custom utility classes */ + .text-balance { + text-wrap: balance; + } +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..22440ad --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,45 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + // Scan all JavaScript files in your project + './**/*.js', + './**/*.jsx', + './examples/**/*.js', + './components/**/*.js', + './pages/**/*.js', + + // Specifically target OpenScript component patterns + // This ensures Tailwind scans h.div({ class: "..." }) patterns + ], + + theme: { + extend: { + // Custom theme extensions can go here + colors: { + // OpenScript brand colors (example) + 'os-primary': '#3490dc', + 'os-secondary': '#ffed4e', + 'os-danger': '#e3342f', + } + }, + }, + + plugins: [ + // Add Tailwind plugins here if needed + // e.g., @tailwindcss/forms, @tailwindcss/typography + ], + + // JIT mode configuration + mode: 'jit', + + // Safelist classes that might be dynamically generated + safelist: [ + // Example: preserve utility classes used dynamically + { + pattern: /bg-(red|green|blue)-(100|200|300|400|500|600|700|800|900)/, + }, + { + pattern: /text-(sm|base|lg|xl|2xl|3xl)/, + } + ] +} diff --git a/templates/basic/.gitignore b/templates/basic/.gitignore new file mode 100644 index 0000000..d0f982f --- /dev/null +++ b/templates/basic/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build outputs +dist +dist-ssr +*.local + +# Editor +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.*.local diff --git a/templates/basic/README.md b/templates/basic/README.md new file mode 100644 index 0000000..97ba2e8 --- /dev/null +++ b/templates/basic/README.md @@ -0,0 +1,42 @@ +# {{PROJECT_NAME}} + +A new OpenScript project created with `create-ojs-app`. + +## Getting Started + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev + +# Build for production +npm run build +``` + +## Project Structure + +``` +src/ +├── components/ # OpenScript components +│ ├── App.js # Root component +│ └── Counter.js # Example counter component +├── style.css # Global styles +└── main.js # Application entry point +``` + +## Learn More + +- [OpenScript Documentation](https://github.com/yourusername/openscriptjs) +- [OpenScript Examples](https://github.com/yourusername/openscriptjs/tree/main/examples) + +## Features + +- ⚡️ Fast development with Vite +- 🎨 Component-based architecture +- 📦 Reactive state management +- 🔄 Built-in routing +- 🎯 Event-driven communication + +Enjoy building with OpenScript! 🚀 diff --git a/templates/basic/index.html b/templates/basic/index.html new file mode 100644 index 0000000..2448839 --- /dev/null +++ b/templates/basic/index.html @@ -0,0 +1,15 @@ + + + + + + + OpenScript App + + + +
+ + + + \ No newline at end of file diff --git a/templates/basic/src/components/App.js b/templates/basic/src/components/App.js new file mode 100644 index 0000000..2bd3f45 --- /dev/null +++ b/templates/basic/src/components/App.js @@ -0,0 +1,26 @@ +/** + * Root Application Component + */ + +import { Component, h, ojs } from 'openscriptjs'; +import Counter from './Counter.js'; + +export default class App extends Component { + render(...args) { + return h.div( + { class: "app-container" }, + h.header( + { class: "app-header" }, + h.h1("Welcome to OpenScript!"), + h.p("A lightweight, reactive JavaScript framework") + ), + h.main( + { class: "app-main" }, + h.Counter() + ), + ...args + ); + } +} + +ojs(App); \ No newline at end of file diff --git a/templates/basic/src/components/Counter.js b/templates/basic/src/components/Counter.js new file mode 100644 index 0000000..f5d79e9 --- /dev/null +++ b/templates/basic/src/components/Counter.js @@ -0,0 +1,50 @@ +/** + * Counter Component - Simple interactive example + */ + +import { Component, h, ojs, state } from 'openscriptjs'; + +export default class Counter extends Component { + constructor() { + super(); + this.count = state(0); + } + + increment() { + this.count.value++; + } + + decrement() { + this.count.value--; + } + + reset() { + this.count.value = 0; + } + + render(...args) { + return h.div( + { class: "counter" }, + h.h2("Counter Example"), + h.div( + { class: "counter-display" }, + h.span({ class: "count" }, this.count.value) + ), + h.div( + { class: "counter-buttons" }, + h.button({ + listeners: { click: this.decrement.bind(this) } + }, "-"), + h.button({ + listeners: { click: this.reset.bind(this) } + }, "Reset"), + h.button({ + listeners: { click: this.increment.bind(this) } + }, "+") + ), + ...args + ); + } +} + +ojs(Counter); diff --git a/templates/basic/src/contexts.js b/templates/basic/src/contexts.js new file mode 100644 index 0000000..637aa13 --- /dev/null +++ b/templates/basic/src/contexts.js @@ -0,0 +1,25 @@ +/** + * Context and State Initialization for the App + * Global state management following OpenScript best practices + */ + +import { Context, context, dom, putContext } from "openscriptjs"; + +putContext(["global"], "AppContext"); + +/** + * Global Context - Application-wide state + * @type {Context} + */ +export const gc = context("global"); + +export function setupContexts() { + gc.states({ + appName: "Setup App", + version: "1.0.0", + isInitialized: false, + }); + + // Set root element for global context + gc.rootElement = dom.id("app-root"); +} diff --git a/templates/basic/src/events.js b/templates/basic/src/events.js new file mode 100644 index 0000000..bbceceb --- /dev/null +++ b/templates/basic/src/events.js @@ -0,0 +1,11 @@ +/** + * Application Events + * Structure: Nested object where keys become namespaced event names + * Example: app.started becomes "app:started" + */ +export default appEvents = { + app: { + started: true, + ready: true, + }, +}; diff --git a/templates/basic/src/main.js b/templates/basic/src/main.js new file mode 100644 index 0000000..dfbb9b6 --- /dev/null +++ b/templates/basic/src/main.js @@ -0,0 +1,21 @@ +/** + * Main entry point for your OpenScript application + */ + +// this must come first to ensure that +// all events the system needs have been +// registered before any component is +// initialized +import { configureApp } from './ojs.config'; +import { router } from 'openscriptjs'; +import { setupContexts } from './contexts'; +import { setupRoutes } from './routes'; + +configureApp(); +setupContexts(); +setupRoutes(); + +// start the app +router.listen(); + +console.log('✓ OpenScript app initialized'); diff --git a/templates/basic/src/ojs.config.js b/templates/basic/src/ojs.config.js new file mode 100644 index 0000000..fd65f0f --- /dev/null +++ b/templates/basic/src/ojs.config.js @@ -0,0 +1,75 @@ +import { broker, router } from "openscriptjs"; +import { appEvents } from "./events"; + +/*---------------------------------- + | Do OpenScript Configurations Here + |---------------------------------- +*/ + +export function configureApp(){ + /*----------------------------------- + | Set the global runtime prefix. + | This prefix will be appended + | to every path before resolution. + | So ensure when defining routes, + | you have it as the main prefix. + |------------------------------------ +*/ + router.runtimePrefix(""); + + /**---------------------------------- + * + * Set the default route path here + * ---------------------------------- + */ + router.basePath(""); + + /*-------------------------------- + | Set the logs clearing interval + | for the broker to remove stale + | events. (milliseconds) + |-------------------------------- +*/ + broker.CLEAR_LOGS_AFTER = 30000; + + /*-------------------------------- + | Set how old an event must be + | to be deleted from the broker's + | event log during logs clearing + |-------------------------------- +*/ + broker.TIME_TO_GC = 10000; + + /*------------------------------------------- + | Start the garbage + | collector for the broker + |------------------------------------------- +*/ + broker.removeStaleEvents(); + + /*------------------------------------------ + | Should the broker display events + | in the console as they are fired + |------------------------------------------ +*/ + if (/^(127\.0\.0\.1|localhost|.*\.test)$/.test(router.url().hostname)) { + broker.withLogs(false); + } + + /** + * --------------------------------------------- + * Should the broker require events registration. + * This ensures that only registered events + * can be listened to and fire by the broker. + * --------------------------------------------- + */ + broker.requireEventsRegistration(false); + + /** + * --------------------------------------------- + * Register events with the broker + * --------------------------------------------- + */ + + broker.registerEvents(appEvents); +}; diff --git a/templates/basic/src/routes.js b/templates/basic/src/routes.js new file mode 100644 index 0000000..9f71d53 --- /dev/null +++ b/templates/basic/src/routes.js @@ -0,0 +1,32 @@ +/** + * Routes for Todo App + * Defines application routing using OpenScript router + */ + +import { router, h, dom } from "openscriptjs"; +import { gc } from "./contexts.js"; + +export function setupRoutes() { + // Default route - redirect to home + router.default(() => router.to("home")); + + /** + * Helper to render a component to the root element + * @param {Component} component - Component to render + */ + const app = (component) => { + return h.App(component, { + parent: gc.rootElement, + resetParent: true, // Clear parent before rendering + }); + }; + + router.on( + "/", + () => { + console.log("Route: Home"); + app(h.div("Hello OpenScript")); + }, + "home" + ); +} diff --git a/templates/basic/src/style.css b/templates/basic/src/style.css new file mode 100644 index 0000000..26f7382 --- /dev/null +++ b/templates/basic/src/style.css @@ -0,0 +1,95 @@ +/* Basic Styles for OpenScript App */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, sans-serif; + line-height: 1.6; + color: #333; + background: #f5f5f5; +} + +.app-container { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 2rem; + text-align: center; +} + +.app-header h1 { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.app-header p { + font-size: 1.2rem; + opacity: 0.9; +} + +.app-main { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; +} + +.counter { + background: white; + border-radius: 12px; + padding: 2rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + text-align: center; + min-width: 300px; +} + +.counter h2 { + margin-bottom: 1.5rem; + color: #667eea; +} + +.counter-display { + margin: 2rem 0; +} + +.count { + font-size: 4rem; + font-weight: bold; + color: #333; +} + +.counter-buttons { + display: flex; + gap: 1rem; + justify-content: center; +} + +.counter-buttons button { + background: #667eea; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: background 0.2s; +} + +.counter-buttons button:hover { + background: #5568d3; +} + +.counter-buttons button:active { + transform: scale(0.98); +} diff --git a/templates/basic/vite.config.js b/templates/basic/vite.config.js new file mode 100644 index 0000000..00d18e8 --- /dev/null +++ b/templates/basic/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 3000, + open: true + }, + build: { + outDir: 'dist', + sourcemap: true + } +}); diff --git a/templates/bootstrap/.gitignore b/templates/bootstrap/.gitignore new file mode 100644 index 0000000..d0f982f --- /dev/null +++ b/templates/bootstrap/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build outputs +dist +dist-ssr +*.local + +# Editor +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.*.local diff --git a/templates/bootstrap/README.md b/templates/bootstrap/README.md new file mode 100644 index 0000000..e58fe42 --- /dev/null +++ b/templates/bootstrap/README.md @@ -0,0 +1,54 @@ +# {{PROJECT_NAME}} + +A new OpenScript project created with `create-ojs-app` using the **Bootstrap template**. + +## Getting Started + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev + +# Build for production +npm run build +``` + +## Project Structure + +``` +src/ +├── components/ # OpenScript components +│ ├── App.js # Root component with Bootstrap layout +│ └── Counter.js # Example counter with Bootstrap UI +├── style.css # Custom styles (complements Bootstrap) +└── main.js # Application entry point +``` + +## Bootstrap Features + +This template uses Bootstrap 5.3.2 via CDN: + +- Responsive grid system +- Card components +- Button groups +- Badges and progress bars +- Font Awesome icons + +## Learn More + +- [OpenScript Documentation](https://github.com/yourusername/openscriptjs) +- [Bootstrap Documentation](https://getbootstrap.com/docs/5.3/) +- [Font Awesome Icons](https://fontawesome.com/icons) + +## Features + +- ⚡️ Fast development with Vite +- 🎨 Bootstrap 5.3 UI components +- 📦 Reactive state management +- 🔄 Built-in routing +- 🎯 Event-driven communication +- 💅 Font Awesome icons + +Enjoy building with OpenScript & Bootstrap! 🚀 diff --git a/templates/bootstrap/index.html b/templates/bootstrap/index.html new file mode 100644 index 0000000..f777199 --- /dev/null +++ b/templates/bootstrap/index.html @@ -0,0 +1,25 @@ + + + + + + + OpenScript App + + + + + + + + + +
+ + + + + + + + \ No newline at end of file diff --git a/templates/bootstrap/src/components/App.js b/templates/bootstrap/src/components/App.js new file mode 100644 index 0000000..0603990 --- /dev/null +++ b/templates/bootstrap/src/components/App.js @@ -0,0 +1,55 @@ +/** + * Root Application Component with Bootstrap + */ + +import { Component, h, ojs } from 'openscriptjs'; + +export default class App extends Component { + render(...args) { + return h.div( + { class: "min-vh-100 bg-light" }, + + // Header with gradient background + h.header( + { class: "bg-gradient text-white text-center py-5", style: "background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);" }, + h.div( + { class: "container" }, + h.h1({ class: "display-4 fw-bold mb-3" }, + h.i({ class: "fas fa-rocket me-3" }), + "Welcome to OpenScript!" + ), + h.p({ class: "lead" }, "A lightweight, reactive JavaScript framework built with Bootstrap") + ) + ), + + // Main content + h.main( + { class: "container py-5" }, + h.div( + { class: "row justify-content-center" }, + h.div( + { class: "col-md-8 col-lg-6" }, + h.Counter() + ) + ) + ), + + // Footer + h.footer( + { class: "bg-dark text-white text-center py-4 mt-5" }, + h.div( + { class: "container" }, + h.p({ class: "mb-0" }, + "Built with ", + h.i({ class: "fas fa-heart text-danger" }), + " using OpenScript & Bootstrap" + ) + ) + ), + + ...args + ); + } +} + +ojs(App); diff --git a/templates/bootstrap/src/components/Counter.js b/templates/bootstrap/src/components/Counter.js new file mode 100644 index 0000000..70f677f --- /dev/null +++ b/templates/bootstrap/src/components/Counter.js @@ -0,0 +1,106 @@ +/** + * Counter Component with Bootstrap - Simple interactive example + */ + +import { Component, h, ojs, state } from 'openscriptjs'; + +export default class Counter extends Component { + constructor() { + super(); + this.count = state(0); + } + + increment() { + this.count.value++; + } + + decrement() { + this.count.value--; + } + + reset() { + this.count.value = 0; + } + + get badgeClass() { + if (this.count.value > 0) return 'bg-success'; + if (this.count.value < 0) return 'bg-danger'; + return 'bg-secondary'; + } + + render(...args) { + return h.div( + { class: "card shadow-lg border-0" }, + + // Card header + h.div( + { class: "card-header bg-primary text-white" }, + h.h3({ class: "mb-0 d-flex align-items-center justify-content-center" }, + h.i({ class: "fas fa-calculator me-2" }), + "Counter Example" + ) + ), + + // Card body + h.div( + { class: "card-body text-center py-5" }, + + // Count display with badge + h.div({ class: "mb-4" }, + h.span( + { class: `badge ${this.badgeClass} fs-1 px-5 py-3` }, + this.count.value + ) + ), + + // Progress bar + h.div({ class: "progress mb-4", style: "height: 10px;" }, + h.div({ + class: `progress-bar ${this.count.value >= 0 ? 'bg-success' : 'bg-danger'}`, + style: `width: ${Math.min(Math.abs(this.count.value) * 10, 100)}%`, + role: "progressbar" + }) + ), + + // Button group + h.div( + { class: "btn-group" , role: "group" }, + h.button({ + class: "btn btn-outline-danger btn-lg", + listeners: { click: this.decrement.bind(this) } + }, + h.i({ class: "fas fa-minus me-2" }), + "Decrement" + ), + h.button({ + class: "btn btn-outline-secondary btn-lg", + listeners: { click: this.reset.bind(this) } + }, + h.i({ class: "fas fa-redo me-2" }), + "Reset" + ), + h.button({ + class: "btn btn-outline-success btn-lg", + listeners: { click: this.increment.bind(this) } + }, + h.i({ class: "fas fa-plus me-2" }), + "Increment" + ) + ) + ), + + // Card footer + h.div( + { class: "card-footer text-muted text-center" }, + h.small( + h.i({ class: "fas fa-info-circle me-1" }), + "Click the buttons to update the counter" + ) + ), + + ...args + ); + } +} + +ojs(Counter); diff --git a/templates/bootstrap/src/main.js b/templates/bootstrap/src/main.js new file mode 100644 index 0000000..5c50d85 --- /dev/null +++ b/templates/bootstrap/src/main.js @@ -0,0 +1,18 @@ +/** + * Main entry point for your OpenScript application + */ + +import { Component, h, router, broker, ojs } from 'openscriptjs'; +import App from './components/App.js'; + + +// Render the app +h.App({ + parent: document.getElementById('app'), + resetParent: true +}); + +// Start the router +router.listen(); + +console.log('✓ OpenScript app initialized'); diff --git a/templates/bootstrap/src/style.css b/templates/bootstrap/src/style.css new file mode 100644 index 0000000..3d840b5 --- /dev/null +++ b/templates/bootstrap/src/style.css @@ -0,0 +1,20 @@ +/* Custom styles to complement Bootstrap */ + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, sans-serif; +} + +/* Smooth transitions */ +.btn, .badge, .progress-bar { + transition: all 0.3s ease; +} + +.btn:active { + transform: scale(0.95); +} + +/* Custom gradient utilities if needed */ +.bg-gradient { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; +} diff --git a/templates/bootstrap/vite.config.js b/templates/bootstrap/vite.config.js new file mode 100644 index 0000000..00d18e8 --- /dev/null +++ b/templates/bootstrap/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 3000, + open: true + }, + build: { + outDir: 'dist', + sourcemap: true + } +}); diff --git a/templates/tailwind/.gitignore b/templates/tailwind/.gitignore new file mode 100644 index 0000000..d0f982f --- /dev/null +++ b/templates/tailwind/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build outputs +dist +dist-ssr +*.local + +# Editor +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.*.local diff --git a/templates/tailwind/README.md b/templates/tailwind/README.md new file mode 100644 index 0000000..97ba2e8 --- /dev/null +++ b/templates/tailwind/README.md @@ -0,0 +1,42 @@ +# {{PROJECT_NAME}} + +A new OpenScript project created with `create-ojs-app`. + +## Getting Started + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev + +# Build for production +npm run build +``` + +## Project Structure + +``` +src/ +├── components/ # OpenScript components +│ ├── App.js # Root component +│ └── Counter.js # Example counter component +├── style.css # Global styles +└── main.js # Application entry point +``` + +## Learn More + +- [OpenScript Documentation](https://github.com/yourusername/openscriptjs) +- [OpenScript Examples](https://github.com/yourusername/openscriptjs/tree/main/examples) + +## Features + +- ⚡️ Fast development with Vite +- 🎨 Component-based architecture +- 📦 Reactive state management +- 🔄 Built-in routing +- 🎯 Event-driven communication + +Enjoy building with OpenScript! 🚀 diff --git a/templates/tailwind/index.html b/templates/tailwind/index.html new file mode 100644 index 0000000..9b0de1c --- /dev/null +++ b/templates/tailwind/index.html @@ -0,0 +1,15 @@ + + + + + + + OpenScript App + + + +
+ + + + \ No newline at end of file diff --git a/templates/tailwind/postcss.config.js b/templates/tailwind/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/templates/tailwind/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/templates/tailwind/src/components/App.js b/templates/tailwind/src/components/App.js new file mode 100644 index 0000000..f07617f --- /dev/null +++ b/templates/tailwind/src/components/App.js @@ -0,0 +1,25 @@ +/** + * Root Application Component with Tailwind + */ + +import { Component, h, ojs } from 'openscriptjs'; + +export default class App extends Component { + render(...args) { + return h.div( + { class: "min-h-screen bg-gradient-to-br from-purple-500 to-pink-500" }, + h.header( + { class: "text-white text-center py-12" }, + h.h1({ class: "text-5xl font-bold mb-2" }, "Welcome to OpenScript!"), + h.p({ class: "text-xl opacity-90" }, "A lightweight, reactive JavaScript framework") + ), + h.main( + { class: "flex justify-center items-center py-12" }, + h.Counter() + ), + ...args + ); + } +} + +ojs(App); \ No newline at end of file diff --git a/templates/tailwind/src/components/Counter.js b/templates/tailwind/src/components/Counter.js new file mode 100644 index 0000000..6bfd407 --- /dev/null +++ b/templates/tailwind/src/components/Counter.js @@ -0,0 +1,53 @@ +/** + * Counter Component with Tailwind - Simple interactive example + */ + +import { Component, h, ojs, state } from 'openscriptjs'; + +export default class Counter extends Component { + constructor() { + super(); + this.count = state(0); + } + + increment() { + this.count.value++; + } + + decrement() { + this.count.value--; + } + + reset() { + this.count.value = 0; + } + + render(...args) { + return h.div( + { class: "bg-white rounded-2xl shadow-2xl p-8 min-w-[300px]" }, + h.h2({ class: "text-3xl font-bold text-purple-600 mb-6 text-center" }, "Counter Example"), + h.div( + { class: "my-8 text-center" }, + h.span({ class: "text-6xl font-bold text-gray-800" }, this.count.value) + ), + h.div( + { class: "flex gap-4 justify-center" }, + h.button({ + class: "px-6 py-3 bg-purple-500 text-white rounded-lg font-semibold hover:bg-purple-600 active:scale-95 transition-all", + listeners: { click: this.decrement.bind(this) } + }, "-"), + h.button({ + class: "px-6 py-3 bg-gray-500 text-white rounded-lg font-semibold hover:bg-gray-600 active:scale-95 transition-all", + listeners: { click: this.reset.bind(this) } + }, "Reset"), + h.button({ + class: "px-6 py-3 bg-purple-500 text-white rounded-lg font-semibold hover:bg-purple-600 active:scale-95 transition-all", + listeners: { click: this.increment.bind(this) } + }, "+") + ), + ...args + ); + } +} + +ojs(Counter); diff --git a/templates/tailwind/src/main.js b/templates/tailwind/src/main.js new file mode 100644 index 0000000..243d08a --- /dev/null +++ b/templates/tailwind/src/main.js @@ -0,0 +1,19 @@ +/** + * Main entry point for your OpenScript application + */ + +import { Component, h, router, broker } from 'openscriptjs'; +import App from './components/App.js'; +import './style.css'; // Import Tailwind styles + + +// Render the app +h.App({ + parent: document.getElementById('app'), + resetParent: true +}); + +// Start the router +router.listen(); + +console.log('✓ OpenScript app initialized'); diff --git a/templates/tailwind/src/style.css b/templates/tailwind/src/style.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/templates/tailwind/src/style.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/templates/tailwind/tailwind.config.js b/templates/tailwind/tailwind.config.js new file mode 100644 index 0000000..99bc1e3 --- /dev/null +++ b/templates/tailwind/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,jsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/templates/tailwind/vite.config.js b/templates/tailwind/vite.config.js new file mode 100644 index 0000000..00d18e8 --- /dev/null +++ b/templates/tailwind/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 3000, + open: true + }, + build: { + outDir: 'dist', + sourcemap: true + } +}); diff --git a/terser.config.json b/terser.config.json deleted file mode 100644 index 09a7da4..0000000 --- a/terser.config.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compress": { - "ecma": 5, - "warnings": false, - "comparisons": false, - "inline": 2 - }, - "mangle": { - "toplevel": true, - "safari10": true, - "properties": false - }, - "output": { - "ecma": 5, - "comments": false, - "ascii_only": true - }, - "keep_classnames": true, - "keep_fnames": true, - "parse": { - "ecma": 8 - } -} diff --git a/test/Broker.test.js b/test/Broker.test.js new file mode 100644 index 0000000..fc3741f --- /dev/null +++ b/test/Broker.test.js @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import Broker from "../src/broker/Broker.js"; + +describe("Broker", () => { + let broker; + + beforeEach(() => { + broker = new Broker(); + }); + + describe("Event Registration", () => { + it("should register events from object", () => { + const events = { + user: { + login: true, + logout: true, + }, + }; + + broker.registerEvents(events); + + // Check if events are properly registered + expect(events.user.login).toBeDefined(); + expect(events.user.logout).toBeDefined(); + }); + + it("should create nested event names", () => { + const events = { + user: { + needs: { + login: true, + }, + }, + }; + + broker.registerEvents(events); + + // The event should be accessible as "user:needs:login" + expect(events.user.needs.login).toBeDefined(); + }); + }); + + describe("Event Listening", () => { + it("should register event listener with on()", async () => { + const callback = vi.fn(); + + broker.on("test:event", callback); + await broker.emit("test:event", { data: "value" }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should call multiple listeners for same event", async () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + broker.on("test:event", callback1); + broker.on("test:event", callback2); + await broker.emit("test:event"); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should pass event data to listeners", async () => { + const callback = vi.fn(); + const eventData = { user: "Alice", id: 123 }; + + broker.on("user:login", callback); + await broker.emit("user:login", eventData); + + expect(callback).toHaveBeenCalledWith(eventData, "user:login"); + }); + }); + + describe("Event Emission", () => { + it("should emit events with send()", async () => { + const callback = vi.fn(); + + broker.on("test:send", callback); + await broker.send("test:send", { message: "hello" }); + + expect(callback).toHaveBeenCalledWith({ message: "hello" }, "test:send"); + }); + + it("should broadcast events", async () => { + const callback = vi.fn(); + + broker.on("test:broadcast", callback); + await broker.broadcast("test:broadcast", { data: "broadcast" }); + + expect(callback).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/Component.test.js b/test/Component.test.js new file mode 100644 index 0000000..a1b98e2 --- /dev/null +++ b/test/Component.test.js @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Component } from "../src/component/Component.js"; +import { h } from "../src/component/h.js"; +import State from "../src/core/State.js"; + +describe("Component", () => { + describe("Component Creation", () => { + it("should create a component instance", () => { + class MyComponent extends Component { + render() { + return h.div("Hello"); + } + } + + const component = new MyComponent(); + expect(component).toBeInstanceOf(Component); + }); + + it("should have default properties", () => { + class MyComponent extends Component { + render() { + return h.div("Test"); + } + } + + const component = new MyComponent(); + expect(component.rendered).toBe(false); + expect(component.parentElement).toBeNull(); + }); + }); + + describe("Component Rendering", () => { + it("should render component with render method", () => { + class MyComponent extends Component { + render() { + return h.div({ class: "test" }, "Hello World"); + } + } + + const component = new MyComponent(); + const element = component.render(); + + expect(element).toBeDefined(); + expect(element.tagName).toBe("DIV"); + expect(element.textContent).toBe("Hello World"); + expect(element.className).toBe("test"); + }); + + it("should pass arguments to render method", () => { + class GreetingComponent extends Component { + render(name, age) { + return h.div(`Hello ${name}, age ${age}`); + } + } + + const component = new GreetingComponent(); + const element = component.render("Alice", 25); + + expect(element.textContent).toBe("Hello Alice, age 25"); + }); + }); + + describe("Component Mounting", () => { + it("should mount component to parent element", () => { + const parent = document.createElement("div"); + + class MyComponent extends Component { + render() { + return h.div("Mounted Content"); + } + } + + const component = new MyComponent(); + component.mount(parent); + + expect(parent.children.length).toBe(1); + expect(parent.textContent).toBe("Mounted Content"); + expect(component.rendered).toBe(true); + expect(component.parentElement).toBe(parent); + }); + }); + + describe("Component with State", () => { + it("should use state in component", () => { + class Counter extends Component { + constructor() { + super(); + this.count = State.state(0); + } + + render() { + return h.div(`Count: ${this.count.value}`); + } + } + + const counter = new Counter(); + const element = counter.render(); + + expect(element.textContent).toBe("Count: 0"); + }); + }); + + describe("Component Lifecycle", () => { + it("should call onCreate hook", () => { + let createCalled = false; + + class MyComponent extends Component { + onCreate() { + createCalled = true; + } + + render() { + return h.div("Test"); + } + } + + const component = new MyComponent(); + expect(createCalled).toBe(true); + }); + + it("should call onMount hook when mounted", () => { + const parent = document.createElement("div"); + let mountCalled = false; + + class MyComponent extends Component { + onMount() { + mountCalled = true; + } + + render() { + return h.div("Test"); + } + } + + const component = new MyComponent(); + component.mount(parent); + + expect(mountCalled).toBe(true); + }); + }); + + describe("Component Update", () => { + it("should update component when update is called", () => { + const parent = document.createElement("div"); + let renderCount = 0; + + class MyComponent extends Component { + render() { + renderCount++; + return h.div(`Render #${renderCount}`); + } + } + + const component = new MyComponent(); + component.mount(parent); + + expect(renderCount).toBe(1); + expect(parent.textContent).toBe("Render #1"); + + component.update(); + + expect(renderCount).toBe(2); + expect(parent.textContent).toBe("Render #2"); + }); + }); +}); diff --git a/test/Context.test.js b/test/Context.test.js new file mode 100644 index 0000000..24cbf9a --- /dev/null +++ b/test/Context.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { Context, putContext, context } from "../src/core/Context.js"; +import { State } from "../src/core/State.js"; + +describe("Context", () => { + describe("Context Creation", () => { + it("should create context with putContext", () => { + putContext("test", "TestContext"); + const ctx = context("test"); + + expect(ctx).toBeInstanceOf(Context); + }); + + it("should create multiple contexts from array", () => { + putContext(["ctx1", "ctx2", "ctx3"], "MultiContext"); + + const ctx1 = context("ctx1"); + const ctx2 = context("ctx2"); + const ctx3 = context("ctx3"); + + expect(ctx1).toBeInstanceOf(Context); + expect(ctx2).toBeInstanceOf(Context); + expect(ctx3).toBeInstanceOf(Context); + }); + }); + + describe("Context State Management", () => { + it("should add states to context with states() helper", () => { + putContext("app", "AppContext"); + const ctx = context("app"); + + ctx.states({ + loading: false, + user: null, + count: 0, + }); + + expect(ctx.loading).toBeDefined(); + expect(ctx.user).toBeDefined(); + expect(ctx.count).toBeDefined(); + expect(ctx.loading.value).toBe(false); + }); + + it("should create reactive state properties", () => { + putContext("user", "UserContext"); + const ctx = context("user"); + + ctx.states({ isLoggedIn: false }); + + let changeDetected = false; + ctx.isLoggedIn.listener(() => { + changeDetected = true; + }); + + ctx.isLoggedIn.value = true; + + expect(changeDetected).toBe(true); + expect(ctx.isLoggedIn.value).toBe(true); + }); + }); + + describe("Context Properties", () => { + it("should allow adding non-reactive properties", () => { + putContext("global", "GlobalContext"); + const ctx = context("global"); + + ctx.appName = "TestApp"; + ctx.version = "1.0.0"; + + expect(ctx.appName).toBe("TestApp"); + expect(ctx.version).toBe("1.0.0"); + }); + + it("should allow mixing reactive and non-reactive properties", () => { + putContext("config", "ConfigContext"); + const ctx = context("config"); + + ctx.apiUrl = "https://api.example.com"; + ctx.states({ authenticated: false }); + + expect(ctx.apiUrl).toBe("https://api.example.com"); + expect(ctx.authenticated.value).toBe(false); + }); + }); + + describe("Context Retrieval", () => { + it("should retrieve same context instance", () => { + putContext("singleton", "SingletonContext"); + + const ctx1 = context("singleton"); + const ctx2 = context("singleton"); + + expect(ctx1).toBe(ctx2); + }); + }); +}); diff --git a/test/MarkupEngine.test.js b/test/MarkupEngine.test.js new file mode 100644 index 0000000..d757682 --- /dev/null +++ b/test/MarkupEngine.test.js @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { h } from "../src/component/h.js"; + +describe("Markup Engine (h)", () => { + describe("Basic Element Creation", () => { + it("should create div element", () => { + const element = h.div("Hello"); + + expect(element.tagName).toBe("DIV"); + expect(element.textContent).toBe("Hello"); + }); + + it("should create element with attributes", () => { + const element = h.div({ class: "container", id: "main" }, "Content"); + + expect(element.className).toBe("container"); + expect(element.id).toBe("main"); + expect(element.textContent).toBe("Content"); + }); + + it("should create nested elements", () => { + const element = h.div(h.h1("Title"), h.p("Paragraph")); + + expect(element.children.length).toBe(2); + expect(element.children[0].tagName).toBe("H1"); + expect(element.children[1].tagName).toBe("P"); + }); + }); + + describe("Element with Multiple Children", () => { + it("should handle multiple text children", () => { + const element = h.div("First", "Second", "Third"); + + expect(element.textContent).toBe("FirstSecondThird"); + }); + + it("should handle mixed children types", () => { + const element = h.div("Text", h.span("Span"), "More text"); + + expect(element.childNodes.length).toBe(3); + }); + }); + + describe("Attributes and Properties", () => { + it("should set boolean attributes", () => { + const element = h.input({ type: "checkbox", checked: true }); + + expect(element.checked).toBe(true); + }); + + it("should set data attributes", () => { + const element = h.div({ "data-id": "123", "data-name": "test" }); + + expect(element.dataset.id).toBe("123"); + expect(element.dataset.name).toBe("test"); + }); + + it("should set style object", () => { + const element = h.div({ style: { color: "red", fontSize: "16px" } }); + + expect(element.style.color).toBe("red"); + expect(element.style.fontSize).toBe("16px"); + }); + }); + + describe("Event Listeners", () => { + it("should attach onclick handler", () => { + let clicked = false; + const element = h.button( + { + onclick: () => { + clicked = true; + }, + }, + "Click" + ); + + element.click(); + expect(clicked).toBe(true); + }); + + it("should attach multiple event handlers", () => { + let clickCount = 0; + let hoverCount = 0; + + const element = h.div({ + onclick: () => { + clickCount++; + }, + onmouseover: () => { + hoverCount++; + }, + }); + + element.click(); + element.dispatchEvent(new Event("mouseover")); + + expect(clickCount).toBe(1); + expect(hoverCount).toBe(1); + }); + }); + + describe("Document Fragments", () => { + it("should create fragment with h.$()", () => { + const fragment = h.$(h.div("First"), h.div("Second")); + + expect(fragment.nodeType).toBe(11); // DOCUMENT_FRAGMENT_NODE + expect(fragment.childNodes.length).toBe(2); + }); + + it("should create fragment with h._()", () => { + const fragment = h._(h.span("A"), h.span("B")); + + expect(fragment.nodeType).toBe(11); + expect(fragment.childNodes.length).toBe(2); + }); + }); + + describe("Common Elements", () => { + it("should create heading elements", () => { + const h1 = h.h1("Title"); + const h2 = h.h2("Subtitle"); + + expect(h1.tagName).toBe("H1"); + expect(h2.tagName).toBe("H2"); + }); + + it("should create form elements", () => { + const input = h.input({ type: "text", placeholder: "Enter name" }); + const button = h.button("Submit"); + + expect(input.tagName).toBe("INPUT"); + expect(input.type).toBe("text"); + expect(button.tagName).toBe("BUTTON"); + }); + + it("should create list elements", () => { + const ul = h.ul(h.li("Item 1"), h.li("Item 2")); + + expect(ul.tagName).toBe("UL"); + expect(ul.children.length).toBe(2); + expect(ul.children[0].tagName).toBe("LI"); + }); + }); + + describe("Special Attributes", () => { + it("should handle parent attribute", () => { + const parent = document.createElement("div"); + const child = h.div({ parent: parent }, "Child"); + + expect(parent.children.length).toBe(1); + expect(parent.firstChild).toBe(child); + }); + + it("should handle resetParent attribute", () => { + const parent = document.createElement("div"); + parent.innerHTML = "Old"; + + const child = h.div({ parent: parent, resetParent: true }, "New"); + + expect(parent.children.length).toBe(1); + expect(parent.textContent).toBe("New"); + }); + }); + + describe("Text Content", () => { + it("should handle string content", () => { + const element = h.p("Simple text"); + expect(element.textContent).toBe("Simple text"); + }); + + it("should handle number content", () => { + const element = h.span(42); + expect(element.textContent).toBe("42"); + }); + + it("should handle empty content", () => { + const element = h.div(); + expect(element.textContent).toBe(""); + }); + }); +}); diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..80167c5 --- /dev/null +++ b/test/README.md @@ -0,0 +1,54 @@ +# OpenScript Test Suite + +This directory contains the test suite for the OpenScript framework using Vitest. + +## Running Tests + +```bash +# Run tests in watch mode (recommended during development) +npm test + +# Run tests once +npm run test:run + +# Run tests with UI +npm run test:ui + +# Run tests with coverage report +npm run test:coverage +``` + +## Test Structure + +- `State.test.js` - Tests for State management system +- `Component.test.js` - Tests for Component class and lifecycle +- `Broker.test.js` - Tests for event broker system +- `MarkupEngine.test.js` - Tests for h (markup engine) +- `Router.test.js` - Tests for routing functionality +- `Context.test.js` - Tests for context management + +## Writing Tests + +Example test structure: + +```javascript +import { describe, it, expect } from "vitest"; +import { YourModule } from "../src/path/to/YourModule.js"; + +describe("YourModule", () => { + it("should do something", () => { + // Arrange + const input = "test"; + + // Act + const result = YourModule.method(input); + + // Assert + expect(result).toBe("expected"); + }); +}); +``` + +## Test Coverage + +After running `npm run test:coverage`, open `coverage/index.html` to view detailed coverage reports. diff --git a/test/Router.test.js b/test/Router.test.js new file mode 100644 index 0000000..cc078fb --- /dev/null +++ b/test/Router.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import Router from "../src/router/Router.js"; + +describe("Router", () => { + let router; + + beforeEach(() => { + router = new Router(); + }); + + describe("Route Registration", () => { + it("should register simple route", () => { + const handler = vi.fn(); + + router.on("/", handler, "home"); + + // Just check that it doesn't throw + expect(true).toBe(true); + }); + + it("should register route with parameters", () => { + const handler = vi.fn(); + + router.on("/users/{id}", handler, "user.view"); + + expect(true).toBe(true); + }); + }); +}); diff --git a/test/State.test.js b/test/State.test.js new file mode 100644 index 0000000..32ae2c3 --- /dev/null +++ b/test/State.test.js @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import State from "../src/core/State.js"; + +describe("State", () => { + describe("State Creation", () => { + it("should create state with initial value", () => { + const count = State.state(0); + expect(count.value).toBe(0); + }); + + it("should create state with object value", () => { + const user = State.state({ name: "John", age: 30 }); + expect(user.value).toEqual({ name: "John", age: 30 }); + }); + + it("should create state with array value", () => { + const items = State.state([1, 2, 3]); + expect(items.value).toEqual([1, 2, 3]); + }); + }); + + describe("State Updates", () => { + it("should update state value", () => { + const count = State.state(0); + count.value = 5; + expect(count.value).toBe(5); + }); + + it("should update object state", () => { + const user = State.state({ name: "John" }); + user.value = { name: "Jane" }; + expect(user.value.name).toBe("Jane"); + }); + }); + + describe("State Listeners", () => { + it("should trigger listener on value change", () => { + const count = State.state(0); + let callCount = 0; + let lastValue = null; + + count.listener((state) => { + callCount++; + lastValue = state.value; + }); + + count.value = 5; + expect(callCount).toBe(1); + expect(lastValue).toBe(5); + }); + + it("should trigger multiple listeners", () => { + const count = State.state(0); + const results = []; + + count.listener((state) => results.push(`listener1: ${state.value}`)); + count.listener((state) => results.push(`listener2: ${state.value}`)); + + count.value = 10; + expect(results).toEqual(["listener1: 10", "listener2: 10"]); + }); + + it("should support one-time listener with once()", () => { + const count = State.state(0); + let callCount = 0; + + count.once((state) => { + callCount++; + }); + + count.value = 1; + count.value = 2; + count.value = 3; + + expect(callCount).toBe(1); + }); + + it("should remove listener with off()", () => { + const count = State.state(0); + let callCount = 0; + + const listenerId = count.listener((state) => { + callCount++; + }); + + count.value = 1; + expect(callCount).toBe(1); + + count.off(listenerId); + count.value = 2; + expect(callCount).toBe(1); // Should still be 1 + }); + }); + + describe("State Comparison", () => { + it("should not trigger listener if value is the same", () => { + const count = State.state(5); + let callCount = 0; + + count.listener(() => { + callCount++; + }); + + count.value = 5; // Same value + expect(callCount).toBe(0); + }); + + it("should trigger listener if value changes", () => { + const count = State.state(5); + let callCount = 0; + + count.listener(() => { + callCount++; + }); + + count.value = 6; + expect(callCount).toBe(1); + }); + }); +}); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..83e343e --- /dev/null +++ b/vite.config.js @@ -0,0 +1,58 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import openScriptComponentPlugin from './build/vite-plugin-openscript.js'; + +export default defineConfig({ + plugins: [ + openScriptComponentPlugin() + ], + + // CSS configuration + css: { + postcss: './postcss.config.js', + }, + + build: { + lib: { + // Entry point for the library + entry: resolve(__dirname, 'src/index.js'), + name: 'OpenScript', + // Output formats + formats: ['es', 'umd'], + fileName: (format) => `openscript.${format}.js` + }, + + rollupOptions: { + // Preserve module structure + output: { + // Preserve original names where possible + preserveModules: false, + // Ensure component names are kept in comments + banner: '/* OpenScript Framework - Built with component name preservation */', + } + }, + + // Source maps for debugging + sourcemap: true, + + // Target modern browsers + target: 'es2020', + + minify: 'terser', + terserOptions: { + // Preserve class names + keep_classnames: true, + keep_fnames: true, + mangle: { + // Don't mangle properties that start with these patterns + reserved: ['Component', 'State', 'Mediator', 'Broker'] + } + } + }, + + resolve: { + alias: { + '@': resolve(__dirname, './'), + } + } +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..216156d --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,49 @@ +import { defineConfig } from "vitest/config"; +import { resolve } from "path"; + +export default defineConfig({ + test: { + // Use happy-dom for fast DOM simulation + environment: "happy-dom", + + // Global test utilities (optional) + globals: true, + + // Coverage configuration + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: [ + "node_modules/", + "dist/", + "examples/", + "templates/", + "build/", + "**/*.config.js", + "**/test/**", + "**/__tests__/**", + ], + }, + + // Test file patterns + include: [ + "**/*.{test,spec}.{js,mjs,cjs}", + "**/__tests__/**/*.{js,mjs,cjs}", + ], + + // Setup files (if needed) + // setupFiles: ['./test/setup.js'], + }, + + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + "@core": resolve(__dirname, "./src/core"), + "@component": resolve(__dirname, "./src/component"), + "@router": resolve(__dirname, "./src/router"), + "@broker": resolve(__dirname, "./src/broker"), + "@mediator": resolve(__dirname, "./src/mediator"), + "@utils": resolve(__dirname, "./src/utils"), + }, + }, +}); From 997316fac7abd93577562dc0d9bcc78d34f8d8ba Mon Sep 17 00:00:00 2001 From: levizwannah Date: Tue, 25 Nov 2025 23:41:41 +0300 Subject: [PATCH 02/46] finished testing --- package.json | 1 + src/broker/BrokerRegistrar.js | 111 +- src/broker/Listener.js | 23 +- src/component/Component.js | 1412 ++++++++++++------------- src/core/AutoLoader.js | 648 ++++++------ src/core/Container.js | 251 +++++ src/core/Context.js | 106 +- src/core/ProxyFactory.js | 6 +- src/core/Runner.js | 78 +- src/index.js | 223 ++-- src/mediator/Mediator.js | 97 +- src/router/Router.js | 783 +++++++------- src/utils/containerHelpers.js | 56 + templates/basic/src/contexts.js | 8 +- templates/basic/src/events.js | 10 +- templates/basic/src/main.js | 4 +- templates/basic/src/ojs.config.js | 18 +- templates/bootstrap/index.html | 5 +- templates/bootstrap/src/contexts.js | 29 + templates/bootstrap/src/events.js | 11 + templates/bootstrap/src/main.js | 26 +- templates/bootstrap/src/ojs.config.js | 85 ++ templates/bootstrap/src/routes.js | 32 + templates/tailwind/index.html | 4 +- templates/tailwind/src/contexts.js | 29 + templates/tailwind/src/events.js | 11 + templates/tailwind/src/main.js | 31 +- templates/tailwind/src/ojs.config.js | 85 ++ templates/tailwind/src/routes.js | 32 + test/Component.test.js | 57 +- test/Context.test.js | 21 +- test/MarkupEngine.test.js | 25 +- test/RegistrationGuard.test.js | 127 +++ test/RunnerSingleton.test.js | 115 ++ test_output.txt | Bin 0 -> 16318 bytes vite.config.js | 107 +- 36 files changed, 2811 insertions(+), 1856 deletions(-) create mode 100644 src/core/Container.js create mode 100644 src/utils/containerHelpers.js create mode 100644 templates/bootstrap/src/contexts.js create mode 100644 templates/bootstrap/src/events.js create mode 100644 templates/bootstrap/src/ojs.config.js create mode 100644 templates/bootstrap/src/routes.js create mode 100644 templates/tailwind/src/contexts.js create mode 100644 templates/tailwind/src/events.js create mode 100644 templates/tailwind/src/ojs.config.js create mode 100644 templates/tailwind/src/routes.js create mode 100644 test/RegistrationGuard.test.js create mode 100644 test/RunnerSingleton.test.js create mode 100644 test_output.txt diff --git a/package.json b/package.json index 9523683..a0009c9 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "jsdom": "^27.2.0", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", + "terser": "^5.44.1", "vite": "^5.0.7", "vitest": "^4.0.13" }, diff --git a/src/broker/BrokerRegistrar.js b/src/broker/BrokerRegistrar.js index dd48873..6b111c1 100644 --- a/src/broker/BrokerRegistrar.js +++ b/src/broker/BrokerRegistrar.js @@ -1,79 +1,70 @@ -import { broker } from "../index.js"; +import { container } from "../core/Container.js"; +import { isClass } from "../utils/helpers.js"; /** * Registers events on the broker */ export default class BrokerRegistrar { - async registerNamespace(namespace, events, obj) { - if (typeof events !== "object") { - console.error( - `Namespace has incorrect declaration syntax: '${namespace}' with value: `, - events, - `in ${obj.constructor.name}` - ); + async registerNamespace(namespace, events, obj) { + if (typeof events !== "object") { + console.error( + `Namespace has incorrect declaration syntax: '${namespace}' with value: `, + events, + `in ${obj.constructor.name}` + ); - return; - } + return; + } - for (let event in events) { - if ( - event.startsWith("$$") || - (typeof events[event] === "object" && - !(typeof events[event] === "function")) - ) { - this.registerNamespace( - `${namespace}:${ - event.startsWith("$$") ? event.substring(2) : event - }`, - events[event], - obj - ); - } else { - let ev = event.split(/_/g).filter((a) => a.length > 0); + for (let event in events) { + if ( + event.startsWith("$$") || + (typeof events[event] === "object" && + !(typeof events[event] === "function")) + ) { + this.registerNamespace( + `${namespace}:${event.startsWith("$$") ? event.substring(2) : event}`, + events[event], + obj + ); + } else { + let ev = event.split(/_/g).filter((a) => a.length > 0); - for (let e of ev) { - this.registerMethod( - `${namespace}:${e}`, - events[event], - obj - ); - } - } + for (let e of ev) { + this.registerMethod(`${namespace}:${e}`, events[event], obj); } + } } + } - async register(o) { - let obj = o; - let seen = new Set(); + async register(o) { + let obj = o; + let seen = new Set(); - do { - for (let method of Object.getOwnPropertyNames(obj)) { - if (seen.has(method)) continue; - if (method.length < 3) continue; - if (!method.startsWith("$$")) continue; + do { + for (let method of Object.getOwnPropertyNames(obj)) { + if (seen.has(method)) continue; + if (method.length < 3) continue; + if (!method.startsWith("$$")) continue; - if (typeof obj[method] !== "function") { - await this.registerNamespace( - method.substring(2), - obj[method], - obj - ); - continue; - } + if (typeof obj[method] !== "function") { + await this.registerNamespace(method.substring(2), obj[method], obj); + continue; + } - this.registerMethod(method.substring(2), obj[method], obj); + this.registerMethod(method.substring(2), obj[method], obj); - seen.add(method); - } - } while ((obj = Object.getPrototypeOf(obj))); - } + seen.add(method); + } + } while ((obj = Object.getPrototypeOf(obj))); + } - async registerMethod(method, listener, object) { - let events = method.split(/_/g).filter((a) => a.length > 0); + async registerMethod(method, listener, object) { + let events = method.split(/_/g).filter((a) => a.length > 0); - for (let ev of events) { - if (ev.length === 0) continue; - broker.on(ev, listener.bind(object)); - } + for (let ev of events) { + if (ev.length === 0) continue; + container.resolve("broker").on(ev, listener.bind(object)); } + } } diff --git a/src/broker/Listener.js b/src/broker/Listener.js index 867dfae..024fc02 100644 --- a/src/broker/Listener.js +++ b/src/broker/Listener.js @@ -4,11 +4,22 @@ import BrokerRegistrar from "./BrokerRegistrar.js"; * A Broker Listener */ export default class Listener { - /** - * Registers with the broker - */ - async register() { - let br = new BrokerRegistrar(); - br.register(this); + /** + * Registers with the broker + */ + async register() { + // Prevent duplicate registration + if (this.__ojsRegistered) { + console.warn( + `Listener "${this.constructor.name}" is already registered. Skipping duplicate registration.` + ); + return; } + + let br = new BrokerRegistrar(); + br.register(this); + + // Mark as registered + this.__ojsRegistered = true; + } } diff --git a/src/component/Component.js b/src/component/Component.js index 47e85e8..f94484d 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -1,864 +1,842 @@ import Emitter from "../core/Emitter.js"; import DOMReconciler from "./DOMReconciler.js"; import BrokerRegistrar from "../broker/BrokerRegistrar.js"; -import { h } from "./h.js"; -import { component } from "../index.js"; import State from "../core/State.js"; +import { container } from "../core/Container.js"; +import { h } from "./h.js"; /** * Base Component Class */ export default class Component { + /** + * Anonymous component ID + */ + static aCId = 0; + + /** + * Generate IDs for the components + */ + static uid = 0; + + /** + * Use for returning fragments + */ + static FRAGMENT = "OJS-SPECIAL-FRAGMENT"; + + constructor(name = null) { /** - * Anonymous component ID + * List of events that the component emits */ - static aCId = 0; + this.EVENTS = { + rendered: "rendered", // component is visible on the dom + rerendered: "rerendered", // component was rerendered + premount: "premount", // component is ready to register + mounted: "mounted", // the component is now registered + prebind: "prebind", // the component is ready to bind + bound: "bound", // the component has bound + markupBound: "markup-bound", // a temporary markup has bound + beforeHidden: "before-hidden", + hidden: "hidden", + unmounted: "unmounted", // removed from the markup engine memory + beforeVisible: "before-visible", // before the markup is made visible + visible: "visible", // the markup is now made visible + }; /** - * Generate IDs for the components + * List of all components that are listening to + * specific events */ - static uid = 0; + this.listening = {}; /** - * Use for returning fragments + * All the states that this component is listening to + * @type {object} */ - static FRAGMENT = "OJS-SPECIAL-FRAGMENT"; - - constructor(name = null) { - /** - * List of events that the component emits - */ - this.EVENTS = { - rendered: "rendered", // component is visible on the dom - rerendered: "rerendered", // component was rerendered - premount: "premount", // component is ready to register - mounted: "mounted", // the component is now registered - prebind: "prebind", // the component is ready to bind - bound: "bound", // the component has bound - markupBound: "markup-bound", // a temporary markup has bound - beforeHidden: "before-hidden", - hidden: "hidden", - unmounted: "unmounted", // removed from the markup engine memory - beforeVisible: "before-visible", // before the markup is made visible - visible: "visible", // the markup is now made visible - }; - - /** - * List of all components that are listening to - * specific events - */ - this.listening = {}; - - /** - * All the states that this component is listening to - * @type {object} - */ - this.states = {}; - - /** - * List of components that this component is listening - * to. - */ - this.listeningTo = {}; - - /** - * Has the component being mounted - */ - this.mounted = false; - - /** - * Has the component bound - */ - this.bound = false; - - /** - * Has the component rendered - */ - this.rendered = false; - - /** - * Has the component rerendered - */ - this.rerendered = false; - - /** - * Is the component visible - */ - this.visible = true; - - /** - * The argument Map for rerendering on state changes - */ - this.argsMap = new Map(); - - /** - * Event Emitter for the component - */ - this.emitter = new Emitter(); - - this.isAnonymous = false; - - this.name = name ?? this.constructor.name; - - this.emitter.once( - this.EVENTS.rendered, - (th) => (th.rendered = true) - ); - this.on(this.EVENTS.hidden, (th) => (th.visible = false)); - this.on(this.EVENTS.rerendered, (th) => (th.rerendered = true)); - this.on(this.EVENTS.bound, (th) => (th.bound = true)); - this.on(this.EVENTS.mounted, (th) => (th.mounted = true)); - this.on(this.EVENTS.visible, (th) => (th.visible = true)); - this.getDeclaredListeners(); - - this.$$ojs = { - routeChanged: () => { - setTimeout(() => { - if (this.markup().length == 0) { - if (this.isAnonymous) { - return h.deleteComponent(this.name); - } - - this.releaseMemory(); - } - }, 1000); - }, - }; - - /** - * Compare two Nodes - */ - this.Reconciler = DOMReconciler; - } + this.states = {}; /** - * Write Clean Up Logic in this function + * List of components that this component is listening + * to. */ - cleanUp() {} + this.listeningTo = {}; /** - * Make the component's method accessible from the - * global window - * @param {string} methodName - the method name - * @param {[*]} args - arguments to pass to the method - * To pass a literal string param use '${param}' in the args. - * For example ['${this}'] this will reference the DOM element. + * Has the component being mounted */ - method(name, args) { - if (!Array.isArray(args)) { - args = [args]; - } - return h.func([this, name], ...args); - } + this.mounted = false; /** - * Get an external Component's method - * to add it to a DOM Element - * @param {string} componentMethod `Component.method` e.g. 'MainNav.notify' - * @param {[*]} args + * Has the component bound */ - xMethod(componentMethod, args) { - let splitted = componentMethod - .trim() - .split(/\./) - .map((a) => a.trim()); - - if (splitted.length < 2) { - console.error( - `${componentMethod} has syntax error. Please use ComponentName.methodName` - ); - } - - return component(splitted[0]).method(splitted[1], args); - } + this.bound = false; /** - * Adds a Listening component - * @param {event} event - * @param {Component} component + * Has the component rendered */ - addListeningComponent(component, event) { - if (this.emitsTo(component, event)) return; - - if (!this.listening[event]) this.listening[event] = new Map(); - this.listening[event].set(component.name, component); - - component.addEmittingComponent(this, event); - } + this.rendered = false; /** - * Adds a component that this component is listening to - * @param {string} event - * @param {Component} component + * Has the component rerendered */ - addEmittingComponent(component, event) { - if (this.listensTo(component, event)) return; - - if (!this.listeningTo[component.name]) - this.listeningTo[component.name] = new Map(); - - this.listeningTo[component.name].set(event, component); - - component.addListeningComponent(this, event); - } + this.rerendered = false; /** - * Checks if this component is listening - * @param {string} event - * @param {Component} component + * Is the component visible */ - emitsTo(component, event) { - return this.listening[event]?.has(component.name) ?? false; - } + this.visible = true; /** - * Checks if this component is listening to the other - * component - * @param {*} event - * @param {*} component + * The argument Map for rerendering on state changes */ - listensTo(component, event) { - return this.listeningTo[component.name]?.has(event) ?? false; - } + this.argsMap = new Map(); /** - * Deletes a component from the listening array - * @param {string} event - * @param {Component} component + * Event Emitter for the component */ - doNotListenTo(component, event) { - this.listeningTo[component.name]?.delete(event); + this.emitter = new Emitter(); - if (this.listeningTo[component.name]?.size == 0) { - delete this.listeningTo[component.name]; - } + this.isAnonymous = false; - if (!component.emitsTo(this, event)) return; + this.name = name ?? this.constructor.name; - component.doNotEmitTo(this, event); - } + this.emitter.once(this.EVENTS.rendered, (th) => (th.rendered = true)); + this.on(this.EVENTS.hidden, (th) => (th.visible = false)); + this.on(this.EVENTS.rerendered, (th) => (th.rerendered = true)); + this.on(this.EVENTS.bound, (th) => (th.bound = true)); + this.on(this.EVENTS.mounted, (th) => (th.mounted = true)); + this.on(this.EVENTS.visible, (th) => (th.visible = true)); + this.getDeclaredListeners(); - /** - * Stops this component from emitting to the other component - * @param {string} event - * @param {Component} component - * @returns - */ - doNotEmitTo(component, event) { - this.listening[event]?.delete(component.name); + this.$$ojs = { + routeChanged: () => { + setTimeout(() => { + if (this.markup().length == 0) { + if (this.isAnonymous) { + return h.deleteComponent(this.name); + } - if (!component.listensTo(this, event)) return; - component.doNotListenTo(this, event); + this.releaseMemory(); + } + }, 1000); + }, + }; + + /** + * Compare two Nodes + */ + this.Reconciler = DOMReconciler; + } + + /** + * Write Clean Up Logic in this function + */ + cleanUp() {} + + /** + * Make the component's method accessible from the + * global window + * @param {string} methodName - the method name + * @param {[*]} args - arguments to pass to the method + * To pass a literal string param use '${param}' in the args. + * For example ['${this}'] this will reference the DOM element. + */ + method(name, args) { + if (!Array.isArray(args)) { + args = [args]; + } + return h.func([this, name], ...args); + } + + /** + * Get an external Component's method + * to add it to a DOM Element + * @param {string} componentMethod `Component.method` e.g. 'MainNav.notify' + * @param {[*]} args + */ + xMethod(componentMethod, args) { + let splitted = componentMethod + .trim() + .split(/\./) + .map((a) => a.trim()); + + if (splitted.length < 2) { + console.error( + `${componentMethod} has syntax error. Please use ComponentName.methodName` + ); } - /** - * Get all Emitters declared in the component - */ - getDeclaredListeners() { - let obj = this; - let seen = new Set(); + return h.getComponent(splitted[0]).method(splitted[1], args); + } + + /** + * Adds a Listening component + * @param {event} event + * @param {Component} component + */ + addListeningComponent(component, event) { + if (this.emitsTo(component, event)) return; + + if (!this.listening[event]) this.listening[event] = new Map(); + this.listening[event].set(component.name, component); + + component.addEmittingComponent(this, event); + } + + /** + * Adds a component that this component is listening to + * @param {string} event + * @param {Component} component + */ + addEmittingComponent(component, event) { + if (this.listensTo(component, event)) return; + + if (!this.listeningTo[component.name]) + this.listeningTo[component.name] = new Map(); + + this.listeningTo[component.name].set(event, component); + + component.addListeningComponent(this, event); + } + + /** + * Checks if this component is listening + * @param {string} event + * @param {Component} component + */ + emitsTo(component, event) { + return this.listening[event]?.has(component.name) ?? false; + } + + /** + * Checks if this component is listening to the other + * component + * @param {*} event + * @param {*} component + */ + listensTo(component, event) { + return this.listeningTo[component.name]?.has(event) ?? false; + } + + /** + * Deletes a component from the listening array + * @param {string} event + * @param {Component} component + */ + doNotListenTo(component, event) { + this.listeningTo[component.name]?.delete(event); + + if (this.listeningTo[component.name]?.size == 0) { + delete this.listeningTo[component.name]; + } - do { - if (!(obj instanceof Component)) break; + if (!component.emitsTo(this, event)) return; - for (let method of Object.getOwnPropertyNames(obj)) { - if (seen.has(method)) continue; + component.doNotEmitTo(this, event); + } - if (typeof this[method] !== "function") continue; - if (method.length < 3) continue; + /** + * Stops this component from emitting to the other component + * @param {string} event + * @param {Component} component + * @returns + */ + doNotEmitTo(component, event) { + this.listening[event]?.delete(component.name); - if (!method.startsWith("$_")) continue; + if (!component.listensTo(this, event)) return; + component.doNotListenTo(this, event); + } - let meta = method.substring(1).split(/\$/g); + /** + * Get all Emitters declared in the component + */ + getDeclaredListeners() { + let obj = this; + let seen = new Set(); - let events = meta[0].split(/_/g); - events.shift(); - let cmpName = this.name; + do { + if (!(obj instanceof Component)) break; - let subjects = meta.slice(1); + for (let method of Object.getOwnPropertyNames(obj)) { + if (seen.has(method)) continue; - if (!subjects?.length) subjects = [this.name, "on"]; + if (typeof this[method] !== "function") continue; + if (method.length < 3) continue; - let methods = { on: true, onAll: true }; + if (!method.startsWith("$_")) continue; - let stack = []; + let meta = method.substring(1).split(/\$/g); - for (let i = 0; i < subjects.length; i++) { - let current = subjects[i]; - stack.push(current); + let events = meta[0].split(/_/g); + events.shift(); + let cmpName = this.name; - while (stack.length) { - i++; - current = subjects[i] ?? null; + let subjects = meta.slice(1); - if (current && methods[current]) { - stack.push(current); - } else { - stack.push("on"); - i--; - } + if (!subjects?.length) subjects = [this.name, "on"]; - let m = stack.pop(); - let cmp = stack.pop(); + let methods = { on: true, onAll: true }; - for (let j = 0; j < events.length; j++) { - let ev = events[j]; + let stack = []; - if (!ev.length) continue; + for (let i = 0; i < subjects.length; i++) { + let current = subjects[i]; + stack.push(current); - h[m](cmp, ev, (component, event, ...args) => { - try { - h - .getComponent(cmpName) - [method]?.bind( - h.getComponent(cmpName) - )(component, event, ...args); - } catch (e) { - console.error(e); - } - }); - } - } - } + while (stack.length) { + i++; + current = subjects[i] ?? null; - seen.add(method); + if (current && methods[current]) { + stack.push(current); + } else { + stack.push("on"); + i--; } - } while ((obj = Object.getPrototypeOf(obj))); - const br = new BrokerRegistrar(); + let m = stack.pop(); + let cmp = stack.pop(); + + for (let j = 0; j < events.length; j++) { + let ev = events[j]; + + if (!ev.length) continue; + + h[m](cmp, ev, (component, event, ...args) => { + try { + h + .getComponent(cmpName) + [method]?.bind(h.getComponent(cmpName))( + component, + event, + ...args + ); + } catch (e) { + console.error(e); + } + }); + } + } + } - br.register(this); + seen.add(method); + } + } while ((obj = Object.getPrototypeOf(obj))); + + const br = new BrokerRegistrar(); + + br.register(this); + } + /** + * Initializes the component and adds it to + * the component map of the markup engine + * @emits mounted + * @emits pre-mount + */ + async mount() { + // Prevent duplicate registration + if (this.__ojsRegistered) { + console.warn( + `Component "${this.name}" is already registered. Skipping duplicate registration.` + ); + return; } - /** - * Initializes the component and adds it to - * the component map of the markup engine - * @emits mounted - * @emits pre-mount - */ - async mount() { - h.component(this.name, this); - this.claimListeners(); - this.emit(this.EVENTS.premount); - await this.bindComponent(); - this.emit(this.EVENTS.mounted); - } + h.component(this.name, this); - /** - * Deletes all the component's markup from the DOM - */ - unmount() { - let all = this.markup(); + this.claimListeners(); + this.emit(this.EVENTS.premount); + await this.bindComponent(); + this.emit(this.EVENTS.mounted); - for (let elem of all) { - elem.remove(); - } + // Mark as registered + this.__ojsRegistered = true; + } - this.releaseMemory(); + /** + * Deletes all the component's markup from the DOM + */ + unmount() { + let all = this.markup(); - return true; + for (let elem of all) { + elem.remove(); } - /** - * Checks if this component has - * elements on the dom and if they are - * visible - */ - checkVisibility() { - let elem = h.dom.querySelector(`ojs-${this.kebab(this.name)}`); - - if ( - elem && - elem.parentElement?.style.display !== "none" && - !this.visible - ) { - return this.show(); - } + this.releaseMemory(); - if ( - elem && - elem.parentElement?.style.display === "none" && - this.visible - ) { - return this.hide(); - } + return true; + } - if ( - elem && - elem.style.display !== "none" && - elem.style.visibility !== "hidden" && - !this.visible - ) { - this.show(); - } + /** + * Checks if this component has + * elements on the dom and if they are + * visible + */ + checkVisibility() { + let elem = h.dom.querySelector(`ojs-${this.kebab(this.name)}`); - if ( - (!elem || - elem.style.display === "none" || - elem.style.visibility === "hidden") && - this.visible - ) { - this.hide(); - } + if (elem && elem.parentElement?.style.display !== "none" && !this.visible) { + return this.show(); } - /** - * Emits an event - * @param {string} event - * @param {Array<*>} args - */ - emit(event, args = []) { - this.emitter.emit(event, this, event, ...args); + if (elem && elem.parentElement?.style.display === "none" && this.visible) { + return this.hide(); } - /** - * Binds this component to the elements on the dom. - * @emits pre-bind - * @emits markup-bound - * @emits bound - */ - async bindComponent() { - this.emit(this.EVENTS.prebind); - - let all = h.dom.querySelectorAll( - `ojs-${this.kebab(this.name)}-tmp--` - ); - - if (all.length == 0 && !this.bindCalled) { - this.bindCalled = true; - setTimeout(this.bindComponent.bind(this), 500); - return; - } - - for (let elem of all) { - let hId = elem.getAttribute("ojs-key"); - - let args = [...h.compArgs.get(hId)]; - h.compArgs.delete(hId); - - this.wrap(...args, { parent: elem, replaceParent: true }); - - this.emit(this.EVENTS.markupBound, [elem, args]); - } - - this.emit(this.EVENTS.bound); - - return true; + if ( + elem && + elem.style.display !== "none" && + elem.style.visibility !== "hidden" && + !this.visible + ) { + this.show(); } - /** - * Converts camel case to kebab case - * @param {string} name - */ - kebab(name) { - let newName = ""; - - for (const c of name) { - if (c.toLocaleUpperCase() === c && newName.length > 1) - newName += "-"; - newName += c.toLocaleLowerCase(); - } - - return newName; + if ( + (!elem || + elem.style.display === "none" || + elem.style.visibility === "hidden") && + this.visible + ) { + this.hide(); } - - /** - * Return all the current DOM elements for this component - * From the parent. - * @param {HTMLElement | null} parent - * @returns - */ - markup(parent = null) { - if (!parent) parent = h.dom; - - return parent.querySelectorAll(`ojs-${this.kebab(this.name)}`); + } + + /** + * Emits an event + * @param {string} event + * @param {Array<*>} args + */ + emit(event, args = []) { + this.emitter.emit(event, this, event, ...args); + } + + /** + * Binds this component to the elements on the dom. + * @emits pre-bind + * @emits markup-bound + * @emits bound + */ + async bindComponent() { + this.emit(this.EVENTS.prebind); + + let all = h.dom.querySelectorAll(`ojs-${this.kebab(this.name)}-tmp--`); + + if (all.length == 0 && !this.bindCalled) { + this.bindCalled = true; + setTimeout(this.bindComponent.bind(this), 500); + return; } - /** - * Hides all the markup of this component - * @emits before-hidden - * @emits hidden - * @returns {bool} - */ - hide() { - this.emit(this.EVENTS.beforeHidden); + for (let elem of all) { + let hId = elem.getAttribute("ojs-key"); - let all = this.markup(); + let args = [...h.compArgs.get(hId)]; + h.compArgs.delete(hId); - for (let elem of all) { - elem.style.display = "none"; - } + this.wrap(...args, { parent: elem, replaceParent: true }); - this.emit(this.EVENTS.hidden); - - return true; + this.emit(this.EVENTS.markupBound, [elem, args]); } - /** - * Remove style-display-none from all this component's markup - * @emits before-visible - * @emits visible - * @returns bool - */ - show() { - this.emit(this.EVENTS.beforeVisible); - - let all = this.markup(); - - for (let elem of all) { - elem.style.display = ""; - } + this.emit(this.EVENTS.bound); - this.emit(this.EVENTS.visible); + return true; + } - return true; - } + /** + * Converts camel case to kebab case + * @param {string} name + */ + kebab(name) { + let newName = ""; - /** - * Ensure that the action will get called - * even if the event was emitted previous - * @param {string} event - * @param {...function} listeners - */ - onAll(event, ...listeners) { - // check if we have previously emitted this event - listeners.forEach((a) => { - if (event in this.emitter.emitted) - a(...this.emitter.emitted[event]); - - this.emitter.on(event, a); - }); + for (const c of name) { + if (c.toLocaleUpperCase() === c && newName.length > 1) newName += "-"; + newName += c.toLocaleLowerCase(); } - /** - * Add Event Listeners to that component - * @param {string} event - * @param {...function} listeners - */ - on(event, ...listeners) { - // check if we have previously emitted this event - listeners.forEach((a) => { - if (Array.isArray(a)) { - a.forEach((f) => this.emitter.on(event, f)); - return; - } - - this.emitter.on(event, a); - }); + return newName; + } + + /** + * Return all the current DOM elements for this component + * From the parent. + * @param {HTMLElement | null} parent + * @returns + */ + markup(parent = null) { + if (!parent) parent = h.dom; + + return parent.querySelectorAll(`ojs-${this.kebab(this.name)}`); + } + + /** + * Hides all the markup of this component + * @emits before-hidden + * @emits hidden + * @returns {bool} + */ + hide() { + this.emit(this.EVENTS.beforeHidden); + + let all = this.markup(); + + for (let elem of all) { + elem.style.display = "none"; } - /** - * Gets all the listeners for itself and adds them to itself - */ - claimListeners() { - if (!h.eventsMap.has(this.name)) return; + this.emit(this.EVENTS.hidden); - let events = h.eventsMap.get(this.name); + return true; + } - for (let event in events) { - events[event].forEach((listener) => { - let func = listener.function; + /** + * Remove style-display-none from all this component's markup + * @emits before-visible + * @emits visible + * @returns bool + */ + show() { + this.emit(this.EVENTS.beforeVisible); - if (listener.type === "all") this.onAll(event, func); - else this.on(event, func); - }); - } + let all = this.markup(); - h.eventsMap.delete(this.name); + for (let elem of all) { + elem.style.display = ""; } - releaseMemory() { - this.cleanUp(); + this.emit(this.EVENTS.visible); + + return true; + } + + /** + * Ensure that the action will get called + * even if the event was emitted previous + * @param {string} event + * @param {...function} listeners + */ + onAll(event, ...listeners) { + // check if we have previously emitted this event + listeners.forEach((a) => { + if (event in this.emitter.emitted) a(...this.emitter.emitted[event]); + + this.emitter.on(event, a); + }); + } + + /** + * Add Event Listeners to that component + * @param {string} event + * @param {...function} listeners + */ + on(event, ...listeners) { + // check if we have previously emitted this event + listeners.forEach((a) => { + if (Array.isArray(a)) { + a.forEach((f) => this.emitter.on(event, f)); + return; + } + + this.emitter.on(event, a); + }); + } + + /** + * Gets all the listeners for itself and adds them to itself + */ + claimListeners() { + if (!h.eventsMap.has(this.name)) return; + + let events = h.eventsMap.get(this.name); + + for (let event in events) { + events[event].forEach((listener) => { + let func = listener.function; + + if (listener.type === "all") this.onAll(event, func); + else this.on(event, func); + }); + } - for (let event in this.listening) { - for (let [_name, component] of this.listening[event]) { - component.doNotListenTo(this, event); - } - } + h.eventsMap.delete(this.name); + } - for (let id in this.states) { - this.states[id]?.off(this.name); - delete this.states[id]; - } + releaseMemory() { + this.cleanUp(); - this.argsMap = new Map(); - this.listeningTo = {}; - this.listening = {}; - - if (this.isAnonymous) { - this.emitter.listeners = {}; - this.emitter.emitted = {}; - } + for (let event in this.listening) { + for (let [_name, component] of this.listening[event]) { + component.doNotListenTo(this, event); + } } - /** - * Renders the Element and returns an HTML Element - * @param {...any} args - * @returns {DocumentFragment|HTMLElement|String|Array} - */ - render(...args) { - return h.ojs(...args); + for (let id in this.states) { + this.states[id]?.off(this.name); + delete this.states[id]; } - /** - * Finds the parent in the argument list - * @param {Array<*>} args - * @returns - */ - getParentAndListen(args) { - let final = { - index: -1, - parent: null, - states: [], - resetParent: false, - replaceParent: false, - firstOfParent: false, - }; + this.argsMap = new Map(); + this.listeningTo = {}; + this.listening = {}; - for (let i in args) { - if ( - args[i] instanceof State || - (args[i] && - typeof args[i].$__name__ !== "undefined" && - args[i].$__name__ == "OpenScript.State") - ) { - args[i].listener(this); - this.states[args[i].$__id__] = args[i]; - final.states.push(args[i].$__id__); - } else if ( - !( - args[i] instanceof DocumentFragment || - args[i] instanceof HTMLElement - ) && - args[i] && - !Array.isArray(args[i]) && - typeof args[i] === "object" && - args[i].parent - ) { - if (args[i].parent) { - final.index = i; - final.parent = args[i].parent; - } + if (this.isAnonymous) { + this.emitter.listeners = {}; + this.emitter.emitted = {}; + } + } + + /** + * Renders the Element and returns an HTML Element + * @param {...any} args + * @returns {DocumentFragment|HTMLElement|String|Array} + */ + render(...args) { + return h.ojs(...args); + } + + /** + * Finds the parent in the argument list + * @param {Array<*>} args + * @returns + */ + getParentAndListen(args) { + let final = { + index: -1, + parent: null, + states: [], + resetParent: false, + replaceParent: false, + firstOfParent: false, + }; + + for (let i in args) { + if ( + args[i] instanceof State || + (args[i] && + typeof args[i].$__name__ !== "undefined" && + args[i].$__name__ == "OpenScript.State") + ) { + args[i].listener(this); + this.states[args[i].$__id__] = args[i]; + final.states.push(args[i].$__id__); + } else if ( + !( + args[i] instanceof DocumentFragment || args[i] instanceof HTMLElement + ) && + args[i] && + !Array.isArray(args[i]) && + typeof args[i] === "object" && + args[i].parent + ) { + if (args[i].parent) { + final.index = i; + final.parent = args[i].parent; + } - const keys = [ - "resetParent", - "replaceParent", - "firstOfParent", - ]; - - for (let reserved of keys) { - if (args[i][reserved]) { - final[reserved] = args[i][reserved]; - delete args[i][reserved]; - } - } + const keys = ["resetParent", "replaceParent", "firstOfParent"]; - delete args[i].parent; - } + for (let reserved of keys) { + if (args[i][reserved]) { + final[reserved] = args[i][reserved]; + delete args[i][reserved]; + } } - return final; + delete args[i].parent; + } } - /** - * Gets the value of object - * @param {any|State} object - * @returns - */ - getValue(object) { - if (object instanceof State) return object.value; - return object; - } - - /** - * Wraps the rendered content - * @emits re-rendered - * @param {...any} args - * @returns - */ - wrap(...args) { - const lastArg = args[args.length - 1]; - let { - index, - parent, - resetParent, - states, - replaceParent, - firstOfParent, - } = this.getParentAndListen(args); - - // check if the render was called due to a state change - if (lastArg && lastArg["called-by-state-change"]) { - let state = lastArg.self; - - delete args[index]; - - let current = - h.dom.querySelectorAll( - `ojs-${this.kebab(this.name)}[s-${state.$__id__}="${ - state.$__id__ - }"]` - ) ?? []; - - let reconciler = new this.Reconciler(); - - current.forEach((e) => { - if (!this.visible) e.style.display = "none"; - else e.style.display = ""; - - // e.textContent = ""; - - let arg = this.argsMap.get(e.getAttribute("uuid")); - let attr = { - // parent: e, - component: this, - event: this.EVENTS.rerendered, - eventParams: [{ markup: e, component: this }], - }; - - let shouldReconcile = true; - - if (e.childNodes.length === 0) { - attr.parent = e; - shouldReconcile = false; - } - - let markup = this.render(...arg, attr); + return final; + } + + /** + * Gets the value of object + * @param {any|State} object + * @returns + */ + getValue(object) { + if (object instanceof State) return object.value; + return object; + } + + /** + * Wraps the rendered content + * @emits re-rendered + * @param {...any} args + * @returns + */ + wrap(...args) { + const lastArg = args[args.length - 1]; + let { index, parent, resetParent, states, replaceParent, firstOfParent } = + this.getParentAndListen(args); + + // check if the render was called due to a state change + if (lastArg && lastArg["called-by-state-change"]) { + let state = lastArg.self; + + delete args[index]; + + let current = + h.dom.querySelectorAll( + `ojs-${this.kebab(this.name)}[s-${state.$__id__}="${state.$__id__}"]` + ) ?? []; + + let reconciler = new this.Reconciler(); + + current.forEach((e) => { + if (!this.visible) e.style.display = "none"; + else e.style.display = ""; + + // e.textContent = ""; + + let arg = this.argsMap.get(e.getAttribute("uuid")); + let attr = { + // parent: e, + component: this, + event: this.EVENTS.rerendered, + eventParams: [{ markup: e, component: this }], + }; - if (shouldReconcile) { - if (Array.isArray(markup)) { - let newParent = e.cloneNode(); - newParent.append(...markup); - reconciler.reconcile(newParent, e); - } else { - reconciler.reconcile(markup, e.childNodes[0]); - } - } - }); + let shouldReconcile = true; - return; + if (e.childNodes.length === 0) { + attr.parent = e; + shouldReconcile = false; } - let event = this.EVENTS.rendered; - - if ( - parent && - (this.getValue(resetParent) || this.getValue(replaceParent)) - ) { - if (!this.markup().length) this.argsMap.clear(); - else { - let all = this.markup(parent); - - all.forEach((elem) => - this.argsMap.delete(elem.getAttribute("uuid")) - ); - } + let markup = this.render(...arg, attr); - if (this.argsMap.size) event = this.EVENTS.rerendered; + if (shouldReconcile) { + if (Array.isArray(markup)) { + let newParent = e.cloneNode(); + newParent.append(...markup); + reconciler.reconcile(newParent, e); + } else { + reconciler.reconcile(markup, e.childNodes[0]); + } } + }); - let uuid = `${Component.uid++}-${new Date().getTime()}`; - - this.argsMap.set(uuid, args ?? []); + return; + } - let attr = { - uuid, - resetParent, - replaceParent, - firstOfParent, - class: "__ojs-c-class__", - }; + let event = this.EVENTS.rendered; - if (parent) attr.parent = parent; + if ( + parent && + (this.getValue(resetParent) || this.getValue(replaceParent)) + ) { + if (!this.markup().length) this.argsMap.clear(); + else { + let all = this.markup(parent); - states.forEach((id) => { - attr[`s-${id}`] = id; - }); + all.forEach((elem) => this.argsMap.delete(elem.getAttribute("uuid"))); + } - let markup = this.render(...args, { withCAttr: true }); + if (this.argsMap.size) event = this.EVENTS.rerendered; + } - if ( - markup.tagName == Component.FRAGMENT && - markup.childNodes.length > 0 - ) { - let children = markup.childNodes; + let uuid = `${Component.uid++}-${new Date().getTime()}`; - return children.length > 1 ? children : children[0]; - } + this.argsMap.set(uuid, args ?? []); - if (!this.visible) attr.style = "display: none;"; + let attr = { + uuid, + resetParent, + replaceParent, + firstOfParent, + class: "__ojs-c-class__", + }; - let cAttributes = {}; + if (parent) attr.parent = parent; - if (markup instanceof HTMLElement) { - cAttributes = JSON.parse( - markup?.getAttribute("c-attr") ?? "{}" - ); - markup.setAttribute("c-attr", ""); - } + states.forEach((id) => { + attr[`s-${id}`] = id; + }); - attr = { - ...attr, - component: this, - event, - eventParams: [{ markup, component: this }], - }; + let markup = this.render(...args, { withCAttr: true }); - return h[`ojs-${this.kebab(this.name)}`](attr, markup, cAttributes); - } + if (markup.tagName == Component.FRAGMENT && markup.childNodes.length > 0) { + let children = markup.childNodes; - isHtml(markup) { - return markup instanceof HTMLElement; + return children.length > 1 ? children : children[0]; } - /** - * Returns a mounted anonymous component's name. - */ - static anonymous() { - let id = Component.aCId++; - - let Cls = class extends Component { - constructor() { - super(); - this.name = `anonym-${id}`; - this.isAnonymous = true; - } - - /** - * Render function takes a state - * @param {State} state - * @param {Function} callback that returns the value to - * put in the markup - * @returns - */ - render(state, callback, ...args) { - let markup = callback(state, ...args); - return h[`ojs-wrapper`](markup, ...args); - } - }; + if (!this.visible) attr.style = "display: none;"; - let c = new Cls(); - c.getDeclaredListeners(); - c.mount(); + let cAttributes = {}; - return c.name; + if (markup instanceof HTMLElement) { + cAttributes = JSON.parse(markup?.getAttribute("c-attr") ?? "{}"); + markup.setAttribute("c-attr", ""); } - /** - * - * @param {string} eventName - * @param {function} listener - */ - addListener(eventName, listener) { - return this.on(eventName, listener); - } - - /** - * - * @param {string} eventName - * @param {function} listener - */ - removeListener(eventName, listener) { - return this.emitter.removeListener(eventName, listener); - } + attr = { + ...attr, + component: this, + event, + eventParams: [{ markup, component: this }], + }; + + return h[`ojs-${this.kebab(this.name)}`](attr, markup, cAttributes); + } + + isHtml(markup) { + return markup instanceof HTMLElement; + } + + /** + * Returns a mounted anonymous component's name. + */ + static anonymous() { + let id = Component.aCId++; + + let Cls = class extends Component { + constructor() { + super(); + this.name = `anonym-${id}`; + this.isAnonymous = true; + } + + /** + * Render function takes a state + * @param {State} state + * @param {Function} callback that returns the value to + * put in the markup + * @returns + */ + render(state, callback, ...args) { + let markup = callback(state, ...args); + return h[`ojs-wrapper`](markup, ...args); + } + }; + + let c = new Cls(); + c.getDeclaredListeners(); + c.mount(); + + return c.name; + } + + /** + * + * @param {string} eventName + * @param {function} listener + */ + addListener(eventName, listener) { + return this.on(eventName, listener); + } + + /** + * + * @param {string} eventName + * @param {function} listener + */ + removeListener(eventName, listener) { + return this.emitter.removeListener(eventName, listener); + } } diff --git a/src/core/AutoLoader.js b/src/core/AutoLoader.js index 2c2a2f1..be0f07b 100644 --- a/src/core/AutoLoader.js +++ b/src/core/AutoLoader.js @@ -1,413 +1,411 @@ import Component from "../component/Component.js"; -import { h } from "../component/h.js"; import { namespace } from "../utils/helpers.js"; +import { container } from "./Container.js"; +import MarkupEngine from "../component/MarkupEngine.js"; +import { h } from "../component/h.js"; +/** + * @type MarkupEngine + */ /** * AutoLoads a class from a file */ export default class AutoLoader { + /** + * Keeps track of the files that have been loaded + */ + static history = new Map(); + + /** + * + * @param {string} dir Directory from which the file should be loaded + * @param {string} extension the extension of the file .js by default + */ + constructor(dir = ".", version = "1.0.0") { /** - * Keeps track of the files that have been loaded + * The Directory or URL in which all JS files are located */ - static history = new Map(); + this.dir = "."; /** - * - * @param {string} dir Directory from which the file should be loaded - * @param {string} extension the extension of the file .js by default + * The extension of the files */ - constructor(dir = ".", version = "1.0.0") { - /** - * The Directory or URL in which all JS files are located - */ - this.dir = "."; - - /** - * The extension of the files - */ - this.extension = ".js"; - - /** - * The version of the files. It will be appended as ?v=1.0 for example - * This enable fresh reloading if necessary - */ - this.version = "1.0.0"; - - this.dir = dir; - this.version = version; - } + this.extension = ".js"; /** - * Changes . to forward slashes - * @param {string|Array} text - * @returns + * The version of the files. It will be appended as ?v=1.0 for example + * This enable fresh reloading if necessary */ - normalize(text) { - if (text instanceof Array) { - return text.join("/"); - } - return text.replace(/\./g, "/"); + this.version = "1.0.0"; + + this.dir = dir; + this.version = version; + } + + /** + * Changes . to forward slashes + * @param {string|Array} text + * @returns + */ + normalize(text) { + if (text instanceof Array) { + return text.join("/"); } - - /** - * Changes / to . - * @param {string|Array} text - * @returns - */ - dot(text) { - if (text instanceof Array) { - return text.join("."); - } - return text.replace(/\//g, "."); + return text.replace(/\./g, "/"); + } + + /** + * Changes / to . + * @param {string|Array} text + * @returns + */ + dot(text) { + if (text instanceof Array) { + return text.join("."); } - + return text.replace(/\//g, "."); + } + + /** + * Splits a file into smaller strings + * based on the class in that file + */ + Splitter = class Splitter { /** - * Splits a file into smaller strings - * based on the class in that file + * Gets the class Signature + * @param {string} content + * @param {int} start + * @param {object<>} signature {name: string, signature: string, start: number, end: number} */ - Splitter = class Splitter { - /** - * Gets the class Signature - * @param {string} content - * @param {int} start - * @param {object<>} signature {name: string, signature: string, start: number, end: number} - */ - classSignature(content, start) { - const signature = { - name: "", - definition: "", - start: -1, - end: -1, - parent: null, - }; - - let startAt = start; + classSignature(content, start) { + const signature = { + name: "", + definition: "", + start: -1, + end: -1, + parent: null, + }; - let output = []; - let tmp = ""; + let startAt = start; - let pushTmp = (index) => { - if (tmp.length === 0) return; + let output = []; + let tmp = ""; - if (output.length === 0) startAt = index; + let pushTmp = (index) => { + if (tmp.length === 0) return; - output.push(tmp); - tmp = ""; - }; + if (output.length === 0) startAt = index; - for (let i = start; i < content.length; i++) { - let ch = content[i]; + output.push(tmp); + tmp = ""; + }; - if (/[\s\r\t\n]/.test(ch)) { - pushTmp(i); + for (let i = start; i < content.length; i++) { + let ch = content[i]; - continue; - } + if (/[\s\r\t\n]/.test(ch)) { + pushTmp(i); - if (/\{/.test(ch)) { - pushTmp(i); - signature.end = i; - - break; - } + continue; + } - tmp += ch; - } + if (/\{/.test(ch)) { + pushTmp(i); + signature.end = i; - signature.start = startAt; + break; + } - if (output.length && output[0] !== "class") { - let temp = []; - temp[0] = output[0]; - temp[1] = output.splice(1).join(" "); - output = temp; - } + tmp += ch; + } - if (output.length % 2 !== 0) - throw Error( - `Invalid Class File. Could not parse \`${content}\` from index ${start} because it doesn't have the proper syntax. ${content.substring( - start - )}` - ); + signature.start = startAt; - if (output.length > 2) { - signature.parent = output[3]; - } + if (output.length && output[0] !== "class") { + let temp = []; + temp[0] = output[0]; + temp[1] = output.splice(1).join(" "); + output = temp; + } - signature.name = output[1]; - signature.definition = output.join(" "); + if (output.length % 2 !== 0) + throw Error( + `Invalid Class File. Could not parse \`${content}\` from index ${start} because it doesn't have the proper syntax. ${content.substring( + start + )}` + ); - return signature; - } + if (output.length > 2) { + signature.parent = output[3]; + } - /** - * Splits the content of the file by - * class - * @param {string} content file content - * @return {Map} class map - */ - classes(content) { - content = content.trim(); - - const stack = []; - const map = new Map(); - const qMap = new Map([ - [`'`, true], - [`"`, true], - ["`", true], - ]); - - let index = 0; - let code = ""; - - while (index < content.length) { - let signature = this.classSignature(content, index); - index = signature.end; - - let ch = content[index]; - stack.push(ch); - - code += signature.definition + " "; - code += ch; - - let text = []; - - index++; - - while (stack.length && index < content.length) { - ch = content[index]; - code += ch; - - if (qMap.has(ch)) { - text.push(ch); - index++; - - while (text.length && index < content.length) { - ch = content[index]; - code += ch; - - let last = text.length - 1; - - if (qMap.has(ch) && ch === text[last]) { - text.pop(); - } else if ( - ch === "\n" && - (text[last] === '"' || text[last] === "'") - ) { - text.pop(); - } - - index++; - } - continue; - } - if (/\{/.test(ch)) stack.push(ch); - if (/\}/.test(ch)) stack.pop(); - - index++; - } - - signature.name = signature.name.split(/\(/)[0]; - - map.set(signature.name, { - extends: signature.parent, - code, - name: signature.name, - signature: signature.definition, - }); - - code = ""; - } + signature.name = output[1]; + signature.definition = output.join(" "); - return map; - } - }; + return signature; + } /** - * - * @param {string} fileName script name without the .js. + * Splits the content of the file by + * class + * @param {string} content file content + * @return {Map} class map */ - async req(fileName) { - if (!/^[\w\._-]+$/.test(fileName)) - throw Error( - `OJS-INVALID-FILE: '${fileName}' is an invalid file name` - ); + classes(content) { + content = content.trim(); - let names = fileName.split(/\./); + const stack = []; + const map = new Map(); + const qMap = new Map([ + [`'`, true], + [`"`, true], + ["`", true], + ]); - if (AutoLoader.history.has(`${this.dir}.${fileName}`)) - return AutoLoader.history.get( - `${this.dir}.${fileName}` - ); + let index = 0; + let code = ""; - let response = await fetch( - `${this.dir}/${this.normalize(fileName)}${this.extension}?v=${ - this.version - }`, - { - headers: { "x-powered-by": "OpenScriptJs" }, - } - ); + while (index < content.length) { + let signature = this.classSignature(content, index); + index = signature.end; - let classes = await response.text(); - let content = classes; + let ch = content[index]; + stack.push(ch); - let classMap = new Map(); - let codeMap = new Map(); - let basePrefix = ""; + code += signature.definition + " "; + code += ch; - try { - let url = new URL(this.dir); - basePrefix = this.dot(url.pathname); - } catch (e) { - basePrefix = this.dot(this.dir); - } + let text = []; + + index++; + + while (stack.length && index < content.length) { + ch = content[index]; + code += ch; - let prefixArray = [ - ...basePrefix.split(/\./g).filter((v) => v.length), - ...names, - ]; + if (qMap.has(ch)) { + text.push(ch); + index++; - let prefix = prefixArray.join("."); - if (prefix.length > 0 && !/^\s+$/.test(prefix)) prefix += "."; + while (text.length && index < content.length) { + ch = content[index]; + code += ch; - let splitter = new this.Splitter(); + let last = text.length - 1; - classes = splitter.classes(content); + if (qMap.has(ch) && ch === text[last]) { + text.pop(); + } else if ( + ch === "\n" && + (text[last] === '"' || text[last] === "'") + ) { + text.pop(); + } - for (let [k, v] of classes) { - let key = prefix + k; - classMap.set(key, [k, v.code]); + index++; + } + continue; + } + if (/\{/.test(ch)) stack.push(ch); + if (/\}/.test(ch)) stack.pop(); + + index++; } - for (let [k, arr] of classMap) { - let parent = classes.get(arr[0]).extends; + signature.name = signature.name.split(/\(/)[0]; - if (parent) { - let original = parent; + map.set(signature.name, { + extends: signature.parent, + code, + name: signature.name, + signature: signature.definition, + }); - if (!/\./g.test(parent)) parent = prefix + parent; + code = ""; + } + + return map; + } + }; + + /** + * + * @param {string} fileName script name without the .js. + */ + async req(fileName) { + if (!/^[\w\._-]+$/.test(fileName)) + throw Error(`OJS-INVALID-FILE: '${fileName}' is an invalid file name`); + + let names = fileName.split(/\./); + + if (AutoLoader.history.has(`${this.dir}.${fileName}`)) + return AutoLoader.history.get(`${this.dir}.${fileName}`); + + let response = await fetch( + `${this.dir}/${this.normalize(fileName)}${this.extension}?v=${ + this.version + }`, + { + headers: { "x-powered-by": "OpenScriptJs" }, + } + ); + + let classes = await response.text(); + let content = classes; + + let classMap = new Map(); + let codeMap = new Map(); + let basePrefix = ""; + + try { + let url = new URL(this.dir); + basePrefix = this.dot(url.pathname); + } catch (e) { + basePrefix = this.dot(this.dir); + } - if (!this.exists(parent)) { - if (!classMap.has(parent)) { - await this.req(parent); - } else { - let pCode = classMap.get(parent); + let prefixArray = [ + ...basePrefix.split(/\./g).filter((v) => v.length), + ...names, + ]; - prefixArray.push(pCode[0]); + let prefix = prefixArray.join("."); + if (prefix.length > 0 && !/^\s+$/.test(prefix)) prefix += "."; - let code = await this.setFile( - prefixArray, - Function(`return (${pCode[1]})`)() - ); + let splitter = new this.Splitter(); - prefixArray.pop(); + classes = splitter.classes(content); - codeMap.set(parent, [pCode[0], code]); - } - } else { - let signature = classes.get(arr[0]).signature; + for (let [k, v] of classes) { + let key = prefix + k; + classMap.set(key, [k, v.code]); + } - let replacement = signature.replace(original, parent); + for (let [k, arr] of classMap) { + let parent = classes.get(arr[0]).extends; - let c = arr[1].replace(signature, replacement); - arr[1] = c; - } - } + if (parent) { + let original = parent; - if (!this.exists(k)) { - prefixArray.push(arr[0]); + if (!/\./g.test(parent)) parent = prefix + parent; - let code = await this.setFile( - prefixArray, - Function(`return (${arr[1]})`)() - ); + if (!this.exists(parent)) { + if (!classMap.has(parent)) { + await this.req(parent); + } else { + let pCode = classMap.get(parent); - prefixArray.pop(); + prefixArray.push(pCode[0]); - codeMap.set(k, [arr[0], code]); - } + let code = await this.setFile( + prefixArray, + Function(`return (${pCode[1]})`)() + ); + + prefixArray.pop(); + + codeMap.set(parent, [pCode[0], code]); + } + } else { + let signature = classes.get(arr[0]).signature; + + let replacement = signature.replace(original, parent); + + let c = arr[1].replace(signature, replacement); + arr[1] = c; } + } - AutoLoader.history.set( - `${this.dir}.${fileName}`, - codeMap - ); + if (!this.exists(k)) { + prefixArray.push(arr[0]); - return codeMap; - } + let code = await this.setFile( + prefixArray, + Function(`return (${arr[1]})`)() + ); - async include(fileName) { - try { - return await this.req(fileName); - } catch (e) {} + prefixArray.pop(); - return null; + codeMap.set(k, [arr[0], code]); + } } - /** - * Adds a class file to the window - * @param {Array} names - */ - async setFile(names, content) { - namespace(names[0]); + AutoLoader.history.set(`${this.dir}.${fileName}`, codeMap); - let obj = window; - let final = names.slice(0, names.length - 1); + return codeMap; + } - for (const n of final) { - if (!obj[n]) obj[n] = {}; - obj = obj[n]; - } + async include(fileName) { + try { + return await this.req(fileName); + } catch (e) {} - obj[names[names.length - 1]] = content; + return null; + } - // Init the component if it is a - // component + /** + * Adds a class file to the window + * @param {Array} names + */ + async setFile(names, content) { + namespace(names[0]); - if (content.prototype instanceof Component) { - let c = new content(); + let obj = window; + let final = names.slice(0, names.length - 1); - if (h.has(c.name)) return; - c.getDeclaredListeners(); - await c.mount(); - } - // if component is function, register it. - else if (typeof content === "function" && !this.isClass(content)) { - let c = new Component(content.name); + for (const n of final) { + if (!obj[n]) obj[n] = {}; + obj = obj[n]; + } - if (h.has(c.name)) return; + obj[names[names.length - 1]] = content; - c.render = content.bind(c); - c.getDeclaredListeners(); - await c.mount(); - } + // Init the component if it is a + // component - return content; - } + if (content.prototype instanceof Component) { + let c = new content(); - isClass(func) { - return ( - typeof func === "function" && - /^class\s/.test(Function.prototype.toString.call(func)) - ); + if (h.has(c.name)) return; + c.getDeclaredListeners(); + await c.mount(); } + // if component is function, register it. + else if (typeof content === "function" && !this.isClass(content)) { + let c = new Component(content.name); - /** - * Checks if an object exists in the window - * @param {string} qualifiedName - */ - exists = (qualifiedName) => { - let names = qualifiedName.split(/\./); - let obj = window[names[0]]; + if (h.has(c.name)) return; - for (let i = 1; i < names.length; i++) { - if (!obj) return false; - obj = obj[names[i]]; - } + c.render = content.bind(c); + c.getDeclaredListeners(); + await c.mount(); + } + + return content; + } + + isClass(func) { + return ( + typeof func === "function" && + /^class\s/.test(Function.prototype.toString.call(func)) + ); + } + + /** + * Checks if an object exists in the window + * @param {string} qualifiedName + */ + exists = (qualifiedName) => { + let names = qualifiedName.split(/\./); + let obj = window[names[0]]; + + for (let i = 1; i < names.length; i++) { + if (!obj) return false; + obj = obj[names[i]]; + } - if (!obj) return false; + if (!obj) return false; - return true; - }; + return true; + }; } diff --git a/src/core/Container.js b/src/core/Container.js new file mode 100644 index 0000000..ad74a07 --- /dev/null +++ b/src/core/Container.js @@ -0,0 +1,251 @@ +/** + * IoC (Inversion of Control) Container + * Manages dependencies and enables dependency injection + */ + +export default class Container { + constructor() { + /** + * Map of registered services + * @type {Map} + */ + this.services = new Map(); + + /** + * Map of singleton instances + * @type {Map} + */ + this.instances = new Map(); + + /** + * Stack for circular dependency detection + * @type {Set} + */ + this.resolvingStack = new Set(); + } + + /** + * Register a singleton service + * @param {string} name - Service identifier + * @param {Function|any} implementation - Class constructor, factory function, or value + * @param {string[]} dependencies - Array of dependency names + * @returns {Container} - For chaining + */ + singleton(name, implementation, dependencies = []) { + this.services.set(name, { + implementation, + dependencies, + lifecycle: "singleton", + }); + return this; + } + + /** + * Register a transient service (new instance every time) + * @param {string} name - Service identifier + * @param {Function} implementation - Class constructor or factory function + * @param {string[]} dependencies - Array of dependency names + * @returns {Container} - For chaining + */ + transient(name, implementation, dependencies = []) { + this.services.set(name, { + implementation, + dependencies, + lifecycle: "transient", + }); + return this; + } + + /** + * Register a factory function + * @param {string} name - Service identifier + * @param {Function} factory - Factory function that returns the service + * @returns {Container} - For chaining + */ + factory(name, factory) { + this.services.set(name, { + implementation: factory, + dependencies: [], + lifecycle: "factory", + }); + return this; + } + + /** + * Register a constant value + * @param {string} name - Service identifier + * @param {any} value - The value to register + * @returns {Container} - For chaining + */ + value(name, value) { + this.instances.set(name, value); + this.services.set(name, { + implementation: value, + dependencies: [], + lifecycle: "value", + }); + return this; + } + + /** + * Resolve a service by name + * @param {string} name - Service identifier + * @returns {any} - The resolved service instance + */ + resolve(name) { + // Check for circular dependencies + if (this.resolvingStack.has(name)) { + const stack = Array.from(this.resolvingStack).join(" -> "); + throw new Error(`Circular dependency detected: ${stack} -> ${name}`); + } + + const service = this.services.get(name); + if (!service) { + throw new Error(`Service "${name}" not registered in container`); + } + + // Return cached singleton instance + if (service.lifecycle === "singleton" && this.instances.has(name)) { + return this.instances.get(name); + } + + // Return value directly + if (service.lifecycle === "value") { + return service.implementation; + } + + // Mark as resolving + this.resolvingStack.add(name); + + try { + // Resolve dependencies + const deps = service.dependencies.map((dep) => this.resolve(dep)); + + let instance; + + if (service.lifecycle === "factory") { + // Call factory function + instance = service.implementation(this); + } else if (typeof service.implementation === "function") { + // Check if it's a class (constructor) + if (this.isClass(service.implementation)) { + instance = new service.implementation(...deps); + } else { + // It's a regular function + instance = service.implementation(...deps); + } + } else { + // It's a value + instance = service.implementation; + } + + // Cache singleton instance + if (service.lifecycle === "singleton") { + this.instances.set(name, instance); + } + + return instance; + } finally { + // Remove from resolving stack + this.resolvingStack.delete(name); + } + } + + /** + * Check if a function is a class + * @param {Function} func - Function to check + * @returns {boolean} + */ + isClass(func) { + return ( + typeof func === "function" && + /^class\s/.test(Function.prototype.toString.call(func)) + ); + } + + /** + * Check if a service is registered + * @param {string} name - Service identifier + * @returns {boolean} + */ + has(name) { + return this.services.has(name); + } + + /** + * Get all registered service names + * @returns {string[]} + */ + getServiceNames() { + return Array.from(this.services.keys()); + } + + /** + * Clear all services and instances + */ + clear() { + this.services.clear(); + this.instances.clear(); + this.resolvingStack.clear(); + } + + /** + * Create a child container that inherits from this one + * @returns {Container} + */ + createChild() { + const child = new Container(); + child.parent = this; + return child; + } + + /** + * Override resolve to check parent container + * @param {string} name + * @returns {any} + */ + resolveWithParent(name) { + if (this.has(name)) { + return this.resolve(name); + } + + if (this.parent) { + return this.parent.resolveWithParent(name); + } + + throw new Error(`Service "${name}" not found in container hierarchy`); + } + + /** + * Inject dependencies into a function + * @param {Function} fn - Function to inject + * @param {string[]} dependencies - Dependency names + * @returns {any} - Result of function call + */ + inject(fn, dependencies = []) { + const deps = dependencies.map((dep) => this.resolve(dep)); + return fn(...deps); + } + + /** + * Create an injectable function wrapper + * @param {string[]} dependencies - Dependency names + * @param {Function} fn - Function to wrap + * @returns {Function} + */ + injectable(dependencies, fn) { + return (...args) => { + const deps = dependencies.map((dep) => this.resolve(dep)); + return fn(...deps, ...args); + }; + } +} + +/** + * Create and export a singleton instance + * This ensures the container is available before any other module loads + * and prevents circular dependency issues + */ +const container = new Container(); + +export { container }; diff --git a/src/core/Context.js b/src/core/Context.js index 1bbdcb2..3bf0462 100644 --- a/src/core/Context.js +++ b/src/core/Context.js @@ -1,52 +1,80 @@ +import State from "./State"; + /** * The Base Context Class for OpenScript */ export default class Context { - constructor() { - /** - * Let us know if this context was loaded from the network - */ - this.__fromNetwork__ = false; - - /** - * Keeps special keys - */ - this.$__specialKeys__ = new Map(); - this.__contextName__ = this.constructor.name + "Context"; - this.__referenceName__ = this.__contextName__; - - for (const key in this) { - this.$__specialKeys__.set(key, true); - } - } - + constructor() { /** - * Puts a value in the context - * @param {string} name - * @param {*} value + * Let us know if this context was loaded from the network */ - put(name, value = {}) { - this[name] = value; - } + this.__fromNetwork__ = false; /** - * Get a value from the context - * @param {string} name - * @returns + * Keeps special keys */ - get(name) { - return this[name]; + this.$__specialKeys__ = new Map(); + this.__contextName__ = this.constructor.name + "Context"; + this.__referenceName__ = this.__contextName__; + + for (const key in this) { + this.$__specialKeys__.set(key, true); } + } - /** - * Reconciles all states in the temporary context with the loaded context - * @param {Map} map - * @param {string} key - */ - reconcile(map, key) { - // Implementation needed - // Assuming this method exists in the original code, but I need to see the rest of it. - // I'll leave it as a placeholder for now or implement if I saw it. - // I saw the start of it in the previous view_file. + /** + * Puts a value in the context + * @param {string} name + * @param {*} value + */ + put(name, value = {}) { + this[name] = value; + } + + /** + * Get a value from the context + * @param {string} name + * @returns + */ + get(name) { + return this[name]; + } + + /** + * Adds states to the context + * @param {Object} states + */ + states(states) { + for (const key in states) { + if (this.$__specialKeys__.has(key)) continue; + this[key] = State.state(states[key]); } + } + + /** + * Reconciles all states in the temporary context with the loaded context + * @param {Map} map + * @param {string} referenceName + */ + reconcile(map, referenceName) { + let cxt = map.get(referenceName); + + if (!cxt) return true; + + for (let key in cxt) { + if (this.$__specialKeys__.has(key)) continue; + + let v = cxt[key]; + + if (v instanceof State && !v.$__changed__) { + v.value = this[key]?.value ?? v.value; + } + + this[key] = v; + } + + this.__fromNetwork__ = true; + + return true; + } } diff --git a/src/core/ProxyFactory.js b/src/core/ProxyFactory.js index 5bc09b8..b82cf0b 100644 --- a/src/core/ProxyFactory.js +++ b/src/core/ProxyFactory.js @@ -6,9 +6,11 @@ export default class ProxyFactory { * Makes a Proxy * @param {class} Target * @param {class} Handler + * @param {Array} targetArgs + * @param {Array} handlerArgs * @returns */ - static make(Target, Handler) { - return new Proxy(new Target(), new Handler()); + static make(Target, Handler, targetArgs = [], handlerArgs = []) { + return new Proxy(new Target(...targetArgs), new Handler(...handlerArgs)); } } diff --git a/src/core/Runner.js b/src/core/Runner.js index ea24429..71c0e19 100644 --- a/src/core/Runner.js +++ b/src/core/Runner.js @@ -3,41 +3,65 @@ import Mediator from "../mediator/Mediator.js"; import Listener from "../broker/Listener.js"; import Context from "./Context.js"; import { isClass } from "../utils/helpers.js"; +import { container } from "./Container.js"; /** * Used to Initialize and Register/Mount Classes upon creation */ export default class Runner { - isClass(func) { - return isClass(func); - } + isClass(func) { + return isClass(func); + } + + /** + * Get a unique key for a class to use in the container + * @param {Function} classRef + * @returns {string} + */ + getClassKey(classRef) { + return `__singleton__${classRef.name}__${classRef.toString().length}`; + } - async run(...cls) { - for (let i = 0; i < cls.length; i++) { - let c = cls[i]; - let instance; + async run(...cls) { + for (let i = 0; i < cls.length; i++) { + let c = cls[i]; + let instance; - if (!this.isClass(c)) { - instance = new Component(c.name); - instance.render = c.bind(instance); - } else { - instance = new c(); - } + if (!this.isClass(c)) { + // Functional component - always create new instance (not a singleton) + instance = new Component(c.name); + instance.render = c.bind(instance); + } else { + // For classes, check if singleton exists in container + const classKey = this.getClassKey(c); - if (instance instanceof Component) { - instance.getDeclaredListeners(); - instance.mount(); - } else if ( - instance instanceof Mediator || - instance instanceof Listener - ) { - instance.register(); - } else if (instance instanceof Context) { - } else { - throw Error( - `You can only pass declarations which extend Component, Mediator or Listener` - ); - } + if (container.has(classKey)) { + // Retrieve existing singleton from container + instance = container.resolve(classKey); + + // Skip if already registered (has __ojsRegistered flag) + if (instance.__ojsRegistered) { + continue; + } + } else { + // Create new instance and register as singleton in container + instance = new c(); + container.singleton(classKey, () => instance, []); } + } + + if (instance instanceof Component) { + instance.getDeclaredListeners(); + await instance.mount(); + } else if (instance instanceof Mediator || instance instanceof Listener) { + await instance.register(); + } else if (instance instanceof Context) { + // Context instances don't need registration + } else { + throw Error( + `You can only pass declarations which extend Component, Mediator or Listener` + ); + } } + } } diff --git a/src/index.js b/src/index.js index 692e11a..11bd133 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ import ContextProvider from "./core/ContextProvider.js"; import Context from "./core/Context.js"; import ProxyFactory from "./core/ProxyFactory.js"; import AutoLoader from "./core/AutoLoader.js"; +import Container, { container } from "./core/Container.js"; import Router from "./router/Router.js"; @@ -34,141 +35,141 @@ const mediatorManager = new MediatorManager(); const loader = new AutoLoader(); const autoload = new AutoLoader(); +// Register global instances in container +container.value("broker", broker); +container.value("router", router); +container.value("contextProvider", contextProvider); +container.value("mediatorManager", mediatorManager); +container.value("loader", loader); +container.value("autoload", autoload); + // Global Helpers const state = State.state; const ojs = (...classDeclarations) => new Runner().run(...classDeclarations); const req = (qualifiedName) => loader.req(qualifiedName); const include = (qualifiedName) => loader.include(qualifiedName); -const v = (state, callback = (state) => state.value, ...args) => h.$anonymous(state, callback, ...args); +const v = (state, callback = (state) => state.value, ...args) => + h.$anonymous(state, callback, ...args); const context = (name) => contextProvider.context(name); -const putContext = (referenceName, qualifiedName) => contextProvider.load(referenceName, qualifiedName); -/** - * @deprecated Use putContext instead. fetchContext will be removed in future versions. - */ -const fetchContext = (referenceName, qualifiedName) => { - console.warn("fetchContext is deprecated. Please use putContext instead."); - return contextProvider.load(referenceName, qualifiedName, true); -}; +const putContext = (referenceName, qualifiedName) => + contextProvider.load(referenceName, qualifiedName); const lazyFor = Utils.lazyFor; const each = Utils.each; const component = (name) => h.getComponent(name); const mediators = (names) => { - for (let qn of names) { - mediatorManager.fetchMediators(qn); - } + for (let qn of names) { + mediatorManager.fetchMediators(qn); + } }; const eData = (meta = {}, message = {}) => { - return new EventData() - .meta(meta) - .message(message) - .encode(); + return new EventData().meta(meta).message(message).encode(); }; const payload = (message = {}, meta = {}) => eData(meta, message); -const route = router; // Utility Shortcuts const ifElse = Utils.ifElse; const coalesce = Utils.coalesce; const dom = DOM; +/** + * Resolves an instance from the container or returns the container if no instance is provided + * @param {string} instance + * @returns {Container|Object} + */ +const app = (instance = null) => { + if(instance === null) return container; + + return container.resolve(instance); +} + // Export everything export { - Runner, - Emitter, - EventData, - State, - ContextProvider, - Context, - ProxyFactory, - AutoLoader, - Router, - Broker, - BrokerRegistrar, - Listener, - Mediator, - MediatorManager, - Component, - DOMReconciler, - MarkupEngine, - MarkupHandler, - h, - Utils, - DOM, - isClass, - namespace, - broker, - router, - route, - contextProvider, - mediatorManager, - loader, - autoload, - state, - ojs, - req, - include, - v, - context, - putContext, - fetchContext, - lazyFor, - each, - ifElse, - coalesce, - dom, - component, - mediators, - eData, - payload + Runner, + Emitter, + EventData, + State, + ContextProvider, + Context, + ProxyFactory, + AutoLoader, + Router, + Broker, + BrokerRegistrar, + Listener, + Mediator, + MediatorManager, + Component, + DOMReconciler, + MarkupEngine, + MarkupHandler, + h, + Utils, + DOM, + app, + isClass, + namespace, + Container, + container, + state, + ojs, + req, + include, + v, + context, + putContext, + lazyFor, + each, + ifElse, + coalesce, + dom, + component, + mediators, + eData, + payload, }; // Default export object export default { - Runner, - Emitter, - EventData, - State, - ContextProvider, - Context, - ProxyFactory, - AutoLoader, - Router, - Broker, - BrokerRegistrar, - Listener, - Mediator, - MediatorManager, - Component, - DOMReconciler, - MarkupEngine, - MarkupHandler, - h, - Utils, - DOM, - isClass, - namespace, - broker, - router, - route, - contextProvider, - mediatorManager, - loader, - autoload, - state, - ojs, - req, - include, - v, - context, - putContext, - fetchContext, - lazyFor, - each, - ifElse, - coalesce, - dom, - component, - mediators, - eData, - payload + Runner, + Emitter, + EventData, + State, + ContextProvider, + Context, + ProxyFactory, + AutoLoader, + Router, + Broker, + BrokerRegistrar, + Listener, + Mediator, + MediatorManager, + Component, + DOMReconciler, + MarkupEngine, + MarkupHandler, + h, + Utils, + DOM, + app, + isClass, + namespace, + Container, + container, + state, + ojs, + req, + include, + v, + context, + putContext, + lazyFor, + each, + ifElse, + coalesce, + dom, + component, + mediators, + eData, + payload, }; diff --git a/src/mediator/Mediator.js b/src/mediator/Mediator.js index 0bcd5d2..91e1330 100644 --- a/src/mediator/Mediator.js +++ b/src/mediator/Mediator.js @@ -1,57 +1,68 @@ import BrokerRegistrar from "../broker/BrokerRegistrar.js"; -import { broker } from "../index.js"; +import { container } from "../core/Container.js"; /** * The Mediator Class */ export default class Mediator { - shouldRegister() { - return true; - } + shouldRegister() { + return true; + } - async register() { - if (!this.shouldRegister()) return; + async register() { + if (!this.shouldRegister()) return; - let br = new BrokerRegistrar(); - br.register(this); + // Prevent duplicate registration + if (this.__ojsRegistered) { + console.warn( + `Mediator "${this.constructor.name}" is already registered. Skipping duplicate registration.` + ); + return; } - /** - * Emits an event through the broker - * @param {string|Array} events - * @param {...string} args data to send - */ - send(events, ...args) { - broker.send(events, ...args); - return this; - } + let br = new BrokerRegistrar(); + br.register(this); - /** - * Emits/Broadcasts an event through the broker - * @param {string|Array} events - * @param {...any} args - */ - broadcast(events, ...args) { - return this.send(events, ...args); - } + // Mark as registered + this.__ojsRegistered = true; + } - /** - * parses a JSON string - * `JSON.parse` - * @param {string} JSONString - * @returns - */ - parse(JSONString) { - return JSON.parse(JSONString); - } + /** + * Emits an event through the broker + * @param {string|Array} events + * @param {...string} args data to send + */ + send(events, ...args) { + container.resolve("broker").send(events, ...args); + return this; + } - /** - * Stringifies a JSON Object - * `JSON.stringify` - * @param {object} object - * @returns - */ - stringify(object) { - return JSON.stringify(object); - } + /** + * Emits/Broadcasts an event through the broker + * @param {string|Array} events + * @param {...any} args + */ + broadcast(events, ...args) { + return this.send(events, ...args); + } + + /** + * parses a JSON string + * `JSON.parse` + * @param {string} JSONString + * @returns + */ + parse(JSONString) { + return JSON.parse(JSONString); + } + + /** + * Stringifies a JSON Object + * `JSON.stringify` + * @param {object} object + * @returns + */ + stringify(object) { + return JSON.stringify(object); + } } diff --git a/src/router/Router.js b/src/router/Router.js index 2b2732d..ddef805 100644 --- a/src/router/Router.js +++ b/src/router/Router.js @@ -1,493 +1,488 @@ import { h } from "../component/h.js"; // Assuming h is here -import { broker } from "../index.js"; // Assuming broker is exported from index +import { container } from "../core/Container.js"; import State from "../core/State.js"; // Assuming State is in core /** * OpenScript's Router Class */ export default class Router { + /** + * + */ + constructor() { /** - * + * Current Prefix + * @type {Array} */ - constructor() { - /** - * Current Prefix - * @type {Array} - */ - this.__prefix = [""]; - - /** - * Prefix to append - * To all the runtime URL changes - * @type {string} - */ - this.__runtimePrefix = ""; - - /** - * Currently resolved string - * @type {string} - */ - this.__resolved = null; - - /** - * The routes Map - * @type {Map|string|function>} - */ - this.map = new Map(); - - this.nameMap = new Map(); - - /** - * The Params in the URL - * @type {object} - */ - this.params = {}; - - /** - * The Query String - * @type {URLSearchParams} - */ - this.qs = {}; - - /** - * Should the root element be cleared? - */ - this.reset; - - /** - * The default path - */ - this.path = ""; - - /** - * Create a route action - */ - this.RouteAction = class RouteAction { - action; - name; - - middleware = () => true; + this.__prefix = [""]; - children = new Map(); - - run() { - return this.action(); - } - }; - - this.GroupedRoute = class GroupedRoute {}; - - this.reset = State.state(false); - - window.addEventListener("popstate", () => { - this.reset.value = true; - this.listen(); - }); - - /** - * Default Action - * @type {function} - */ - this.defaultAction = () => { - alert("404 File Not Found"); - }; + /** + * Prefix to append + * To all the runtime URL changes + * @type {string} + */ + this.__runtimePrefix = ""; - this.RouteName = class RouteName { - name; - route; + /** + * Currently resolved string + * @type {string} + */ + this.__resolved = null; - constructor(name, route) { - this.name = name; - this.route = route; - } - }; + /** + * The routes Map + * @type {Map|string|function>} + */ + this.map = new Map(); - /** - * Allows Grouping of routes - */ - this.PrefixRoute = class PrefixRoute { - /** - * Creates a new PrefixRoute - * @param {Router} router - */ - constructor(router) { - /** - * Parent Router - * @type {Router} - */ - this.router = router; - } - - /** - * Creates a Group - * @param {function} func - * @returns {Router} - */ - group(func = () => {}) { - func(); - - this.router.__prefix.pop(); - - return this.router; - } - }; - } + this.nameMap = new Map(); /** - * Sets the global runtime prefix - * to use when resolving routes - * @param {string} prefix + * The Params in the URL + * @type {object} */ - runtimePrefix(prefix) { - this.__runtimePrefix = prefix; - } + this.params = {}; /** - * Sets the default path - * @param {string} path - * @returns + * The Query String + * @type {URLSearchParams} */ - basePath(path) { - this.path = path; - return this; - } + this.qs = {}; /** - * Sets the default action if a route is not found - * @param {function} action + * Should the root element be cleared? */ - default(action) { - this.defaultAction = action; - } - - isQualifiedUrl(url) { - const urlPattern = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i; - return urlPattern.test(url); - } + this.reset; /** - * Adds an action on URL path - * @param {string} path - * @param {function} action action to perform - * @param {string} name the route name + * The default path */ - on(path, action, name = null) { - let _path = `${this.path}/${this.__prefix.join( - "/" - )}/${path}`.replace(/\/{2,}/g, "/"); - - if (name) { - this.nameMap.set(name, _path); - } + this.path = ""; - const paths = _path.split("/"); - - let key = null; - let map = this.map; + /** + * Create a route action + */ + this.RouteAction = class RouteAction { + action; + name; - for (const cmp of paths) { - if (cmp.length < 1) continue; + middleware = () => true; - key = /^\{\w+\}$/.test(cmp) ? "*" : cmp; + children = new Map(); - let val = map.get(key); - if (!val) val = [cmp, new Map()]; + run() { + return this.action(); + } + }; - map.set(key, val); - map = map.get(key)[1]; - } + this.GroupedRoute = class GroupedRoute {}; - map.set("->", [true, action]); + this.reset = State.state(false); - return this; - } + window.addEventListener("popstate", () => { + this.reset.value = true; + this.listen(); + }); /** - * Used to add multiple routes to the same action - * @param {Array} paths - * @param {function} action - * @param {string[]} names path names respectively + * Default Action + * @type {function} */ - orOn(paths, action, names = []) { - let i = 0; + this.defaultAction = () => { + alert("404 File Not Found"); + }; - for (let path of paths) { - this.on(path, action, names[i] ?? null); - i++; - } + this.RouteName = class RouteName { + name; + route; - return this; - } + constructor(name, route) { + this.name = name; + this.route = route; + } + }; /** - * Creates a prefix for a group of routes - * @param {string} name + * Allows Grouping of routes */ - prefix(name) { - this.__prefix.push(name); - - return new this.PrefixRoute(this); + this.PrefixRoute = class PrefixRoute { + /** + * Creates a new PrefixRoute + * @param {Router} router + */ + constructor(router) { + /** + * Parent Router + * @type {Router} + */ + this.router = router; + } + + /** + * Creates a Group + * @param {function} func + * @returns {Router} + */ + group(func = () => {}) { + func(); + + this.router.__prefix.pop(); + + return this.router; + } + }; + } + + /** + * Sets the global runtime prefix + * to use when resolving routes + * @param {string} prefix + */ + runtimePrefix(prefix) { + this.__runtimePrefix = prefix; + } + + /** + * Sets the default path + * @param {string} path + * @returns + */ + basePath(path) { + this.path = path; + return this; + } + + /** + * Sets the default action if a route is not found + * @param {function} action + */ + default(action) { + this.defaultAction = action; + } + + isQualifiedUrl(url) { + const urlPattern = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i; + return urlPattern.test(url); + } + + /** + * Adds an action on URL path + * @param {string} path + * @param {function} action action to perform + * @param {string} name the route name + */ + on(path, action, name = null) { + let _path = `${this.path}/${this.__prefix.join("/")}/${path}`.replace( + /\/{2,}/g, + "/" + ); + + if (name) { + this.nameMap.set(name, _path); } - /** - * Executes the actions based on the url - */ - listen() { - let url = new URL(window.location.href); - this.params = {}; - this.__resolved = null; - - let paths = url.pathname.split("/").filter((a) => a.length); - - let map = this.map; - let r = []; - - for (const cmp of paths) { - if (cmp.length < 1) continue; - - let next = map.get(cmp); + const paths = _path.split("/"); - if (!next) { - next = map.get("*"); - if (next) this.params[next[0].replace(/[\{\}]/g, "")] = cmp; - } + let key = null; + let map = this.map; - if (!next) { - console.error(`${url.pathname} was not found`); - this.defaultAction(); - return this; - } + for (const cmp of paths) { + if (cmp.length < 1) continue; - r.push(next[0]); - map = next[1]; - } + key = /^\{\w+\}$/.test(cmp) ? "*" : cmp; - this.qs = new URLSearchParams(url.search); - this.__resolved = `/${r.join("/")}`; + let val = map.get(key); + if (!val) val = [cmp, new Map()]; - broker.send("ojs:beforeRouteChange"); + map.set(key, val); + map = map.get(key)[1]; + } - try { - let f = map.get("->")[1]; - f(); - } catch (ex) { - console.error(`${url.pathname} was not found`, ex); - this.defaultAction(); - return this; - } + map.set("->", [true, action]); - this.reset.value = false; + return this; + } - broker.send("ojs:routeChanged"); + /** + * Used to add multiple routes to the same action + * @param {Array} paths + * @param {function} action + * @param {string[]} names path names respectively + */ + orOn(paths, action, names = []) { + let i = 0; - return this; + for (let path of paths) { + this.on(path, action, names[i] ?? null); + i++; } - /** - * Get a route from a registered route name - * @param {string} routeName - * @returns {Router.RouteName} - */ - from(routeName) { - if (!this.nameMap.has(routeName)) { - throw Error(`Unknown Route Name: ${routeName}`); - } + return this; + } - return new this.RouteName(routeName, this.nameMap.get(routeName)); - } + /** + * Creates a prefix for a group of routes + * @param {string} name + */ + prefix(name) { + this.__prefix.push(name); - /** - * Redirects to a named route - * @param {string} routeName - * @param {object} params replaces route params and adds the rest as query strings. - * @returns - */ - toName(routeName, params = {}) { - let rn = this.from(routeName); + return new this.PrefixRoute(this); + } - let p = {}; + /** + * Executes the actions based on the url + */ + listen() { + let url = new URL(window.location.href); + this.params = {}; + this.__resolved = null; - for (let x of rn.route.match(/\{[\w\d-_]+\}/g) ?? []) { - let k = x.substring(1, x.length - 1); - let v = params[k] ?? null; + let paths = url.pathname.split("/").filter((a) => a.length); - if (!v) { - throw Error( - `${rn.route} requires ${x} but it wasn't passed` - ); - } + let map = this.map; + let r = []; - delete params[k]; + for (const cmp of paths) { + if (cmp.length < 1) continue; - p[x] = v; - } + let next = map.get(cmp); - let r = rn.route; + if (!next) { + next = map.get("*"); + if (next) this.params[next[0].replace(/[\{\}]/g, "")] = cmp; + } - for (let k in p) { - r = r.replace(k, p[k]); - } + if (!next) { + console.error(`${url.pathname} was not found`); + this.defaultAction(); + return this; + } - return this.to(r, params); + r.push(next[0]); + map = next[1]; } - /** - * Change the URL path without reloading. Prioritizes route name over route path. - * @param {string} path route or route-name - * @param {object<>} qs Query strings or Route params (if using route name) - */ - to(path, qs = {}) { - if (this.isQualifiedUrl(path)) { - let link = h.a({ - href: path, - style: "display: none;", - target: "_blank", - parent: document.body, - }); - - link.click(); - link.remove(); + this.qs = new URLSearchParams(url.search); + this.__resolved = `/${r.join("/")}`; - return this; - } + container.resolve("broker").send("ojs:beforeRouteChange"); - if (this.nameMap.has(path)) { - return this.toName(path, qs); - } + try { + let f = map.get("->")[1]; + f(); + } catch (ex) { + console.error(`${url.pathname} was not found`, ex); + this.defaultAction(); + return this; + } - let prefix = ""; + this.reset.value = false; - if (!path.replace(/^\//, "").startsWith(this.__runtimePrefix)) { - prefix = this.__runtimePrefix; - } + container.resolve("broker").send("ojs:routeChanged"); - path = `${this.path}/${prefix}/${path}`.trim(); + return this; + } - let paths = path.split("/"); + /** + * Get a route from a registered route name + * @param {string} routeName + * @returns {Router.RouteName} + */ + from(routeName) { + if (!this.nameMap.has(routeName)) { + throw Error(`Unknown Route Name: ${routeName}`); + } - path = ""; + return new this.RouteName(routeName, this.nameMap.get(routeName)); + } - for (let p of paths) { - if (p.length === 0 || /^\s+$/.test(p)) continue; + /** + * Redirects to a named route + * @param {string} routeName + * @param {object} params replaces route params and adds the rest as query strings. + * @returns + */ + toName(routeName, params = {}) { + let rn = this.from(routeName); - if (path.length) path += "/"; + let p = {}; - path += p.trim(); - } + for (let x of rn.route.match(/\{[\w\d-_]+\}/g) ?? []) { + let k = x.substring(1, x.length - 1); + let v = params[k] ?? null; - let s = ""; + if (!v) { + throw Error(`${rn.route} requires ${x} but it wasn't passed`); + } - for (let k in qs) { - if (s.length > 0) s += "&"; - s += `${k}=${qs[k]}`; - } + delete params[k]; - if (s.length > 0) s = `?${s}`; + p[x] = v; + } - this.history().pushState( - { random: Math.random() }, - "", - `/${path}${s}` - ); - this.reset.value = true; + let r = rn.route; - return this.listen(); + for (let k in p) { + r = r.replace(k, p[k]); } - /** - * Gets the base URL - * @param {string} path - * @returns string - */ - baseUrl(path = "") { - return ( - new URL(window.location.href).origin + - (this.path.length > 0 ? "/" + this.path : "") + - "/" + - path - ); + return this.to(r, params); + } + + /** + * Change the URL path without reloading. Prioritizes route name over route path. + * @param {string} path route or route-name + * @param {object<>} qs Query strings or Route params (if using route name) + */ + to(path, qs = {}) { + if (this.isQualifiedUrl(path)) { + let link = h.a({ + href: path, + style: "display: none;", + target: "_blank", + parent: document.body, + }); + + link.click(); + link.remove(); + + return this; } - /** - * Redirects to a page using loading - * @param {string} to - */ - redirect(to) { - return (window.location.href = to); + if (this.nameMap.has(path)) { + return this.toName(path, qs); } - /** - * Refreshes the current page - */ - refresh() { - this.history().go(); - return this; - } + let prefix = ""; - /** - * Goes back to the previous route - * @returns - */ - back() { - this.history().back(); - return this; + if (!path.replace(/^\//, "").startsWith(this.__runtimePrefix)) { + prefix = this.__runtimePrefix; } - /** - * Goes forward to the next route - * @returns - */ - forward() { - this.history().forward(); - return this; - } + path = `${this.path}/${prefix}/${path}`.trim(); - /** - * Returns the Window History Object - * @returns {History} - */ - history() { - return window.history; - } + let paths = path.split("/"); - /** - * Returns the current URL - * @returns {URL} - */ - url() { - return new URL(window.location.href); - } + path = ""; - /** - * Gets the value after hash in the url - * @returns {string} - */ - hash() { - return this.url().hash.replace("#", ""); - } + for (let p of paths) { + if (p.length === 0 || /^\s+$/.test(p)) continue; - /** - * Current Route Path - * @returns string - */ - current() { - return this.url().pathname; + if (path.length) path += "/"; + + path += p.trim(); } - /** - * Checks if the name|route matches the current route. - * @param {string} nameOrRoute - * @returns - */ - is(nameOrRoute) { - if (nameOrRoute == this.__resolved) return true; + let s = ""; - for (let [n, r] of this.nameMap) { - if (n == nameOrRoute) { - return r == this.__resolved; - } - } + for (let k in qs) { + if (s.length > 0) s += "&"; + s += `${k}=${qs[k]}`; + } - return false; + if (s.length > 0) s = `?${s}`; + + this.history().pushState({ random: Math.random() }, "", `/${path}${s}`); + this.reset.value = true; + + return this.listen(); + } + + /** + * Gets the base URL + * @param {string} path + * @returns string + */ + baseUrl(path = "") { + return ( + new URL(window.location.href).origin + + (this.path.length > 0 ? "/" + this.path : "") + + "/" + + path + ); + } + + /** + * Redirects to a page using loading + * @param {string} to + */ + redirect(to) { + return (window.location.href = to); + } + + /** + * Refreshes the current page + */ + refresh() { + this.history().go(); + return this; + } + + /** + * Goes back to the previous route + * @returns + */ + back() { + this.history().back(); + return this; + } + + /** + * Goes forward to the next route + * @returns + */ + forward() { + this.history().forward(); + return this; + } + + /** + * Returns the Window History Object + * @returns {History} + */ + history() { + return window.history; + } + + /** + * Returns the current URL + * @returns {URL} + */ + url() { + return new URL(window.location.href); + } + + /** + * Gets the value after hash in the url + * @returns {string} + */ + hash() { + return this.url().hash.replace("#", ""); + } + + /** + * Current Route Path + * @returns string + */ + current() { + return this.url().pathname; + } + + /** + * Checks if the name|route matches the current route. + * @param {string} nameOrRoute + * @returns + */ + is(nameOrRoute) { + if (nameOrRoute == this.__resolved) return true; + + for (let [n, r] of this.nameMap) { + if (n == nameOrRoute) { + return r == this.__resolved; + } } + + return false; + } } diff --git a/src/utils/containerHelpers.js b/src/utils/containerHelpers.js new file mode 100644 index 0000000..ff159e1 --- /dev/null +++ b/src/utils/containerHelpers.js @@ -0,0 +1,56 @@ +/** + * Service Container Helper + * Provides convenient methods for setting up the IoC container + */ + +import { container } from "../core/Container.js"; + +/** + * Register a service in the container + * @param {string} name - Service name + * @param {Function|any} implementation - Service implementation + * @param {string[]} dependencies - Service dependencies + * @param {string} lifecycle - 'singleton' | 'transient' | 'factory' | 'value' + */ +export function registerService( + name, + implementation, + dependencies = [], + lifecycle = "singleton" +) { + switch (lifecycle) { + case "singleton": + container.singleton(name, implementation, dependencies); + break; + case "transient": + container.transient(name, implementation, dependencies); + break; + case "factory": + container.factory(name, implementation); + break; + case "value": + container.value(name, implementation); + break; + default: + throw new Error(`Unknown lifecycle: ${lifecycle}`); + } +} + +/** + * Resolve a service from the container + * @param {string} name - Service name + * @returns {any} - Resolved service + */ +export function resolve(name) { + return container.resolve(name); +} + +/** + * Create an injectable function + * @param {string[]} dependencies - Dependency names + * @param {Function} fn - Function to inject + * @returns {Function} - Injectable function + */ +export function injectable(dependencies, fn) { + return container.injectable(dependencies, fn); +} diff --git a/templates/basic/src/contexts.js b/templates/basic/src/contexts.js index 637aa13..08149cf 100644 --- a/templates/basic/src/contexts.js +++ b/templates/basic/src/contexts.js @@ -1,9 +1,9 @@ /** * Context and State Initialization for the App - * Global state management following OpenScript best practices + * Using IoC Container for dependency injection */ -import { Context, context, dom, putContext } from "openscriptjs"; +import { Context, context, dom, putContext, app } from "openscriptjs"; putContext(["global"], "AppContext"); @@ -22,4 +22,8 @@ export function setupContexts() { // Set root element for global context gc.rootElement = dom.id("app-root"); + + // Register context in IoC container + app.value("gc", gc); + app.value("globalContext", gc); } diff --git a/templates/basic/src/events.js b/templates/basic/src/events.js index bbceceb..65c1f11 100644 --- a/templates/basic/src/events.js +++ b/templates/basic/src/events.js @@ -3,9 +3,9 @@ * Structure: Nested object where keys become namespaced event names * Example: app.started becomes "app:started" */ -export default appEvents = { - app: { - started: true, - ready: true, - }, +export const appEvents = { + app: { + started: true, + ready: true, + }, }; diff --git a/templates/basic/src/main.js b/templates/basic/src/main.js index dfbb9b6..79d646c 100644 --- a/templates/basic/src/main.js +++ b/templates/basic/src/main.js @@ -7,7 +7,7 @@ // registered before any component is // initialized import { configureApp } from './ojs.config'; -import { router } from 'openscriptjs'; +import { app } from 'openscriptjs'; import { setupContexts } from './contexts'; import { setupRoutes } from './routes'; @@ -16,6 +16,6 @@ setupContexts(); setupRoutes(); // start the app -router.listen(); +app('router').listen(); console.log('✓ OpenScript app initialized'); diff --git a/templates/basic/src/ojs.config.js b/templates/basic/src/ojs.config.js index fd65f0f..cba9bc5 100644 --- a/templates/basic/src/ojs.config.js +++ b/templates/basic/src/ojs.config.js @@ -1,12 +1,15 @@ -import { broker, router } from "openscriptjs"; -import { appEvents } from "./events"; +import { app } from "openscriptjs"; +import { appEvents } from "./events.js"; /*---------------------------------- | Do OpenScript Configurations Here |---------------------------------- */ -export function configureApp(){ +const router = app('router'); +const broker = app('broker'); + +export function configureApp() { /*----------------------------------- | Set the global runtime prefix. | This prefix will be appended @@ -72,4 +75,11 @@ export function configureApp(){ */ broker.registerEvents(appEvents); -}; + + /** + * --------------------------------------------- + * Register core services in IoC container + * --------------------------------------------- + */ + container.value("appEvents", appEvents); +} diff --git a/templates/bootstrap/index.html b/templates/bootstrap/index.html index f777199..434f26d 100644 --- a/templates/bootstrap/index.html +++ b/templates/bootstrap/index.html @@ -14,9 +14,8 @@ -
- - +
+ diff --git a/templates/bootstrap/src/contexts.js b/templates/bootstrap/src/contexts.js new file mode 100644 index 0000000..94549d5 --- /dev/null +++ b/templates/bootstrap/src/contexts.js @@ -0,0 +1,29 @@ +/** + * Context and State Initialization for the App + * Using IoC Container for dependency injection + */ + +import { Context, context, dom, putContext, app } from "openscriptjs"; + +putContext(["global"], "AppContext"); + +/** + * Global Context - Application-wide state + * @type {Context} + */ +export const gc = context("global"); + +export function setupContexts() { + gc.states({ + appName: "Bootstrap App", + version: "1.0.0", + isInitialized: false, + }); + + // Set root element for global context + gc.rootElement = dom.id("app-root"); + + // Register context in IoC container + app().value("gc", gc); + app().value("globalContext", gc); +} diff --git a/templates/bootstrap/src/events.js b/templates/bootstrap/src/events.js new file mode 100644 index 0000000..65c1f11 --- /dev/null +++ b/templates/bootstrap/src/events.js @@ -0,0 +1,11 @@ +/** + * Application Events + * Structure: Nested object where keys become namespaced event names + * Example: app.started becomes "app:started" + */ +export const appEvents = { + app: { + started: true, + ready: true, + }, +}; diff --git a/templates/bootstrap/src/main.js b/templates/bootstrap/src/main.js index 5c50d85..b117cf1 100644 --- a/templates/bootstrap/src/main.js +++ b/templates/bootstrap/src/main.js @@ -2,17 +2,21 @@ * Main entry point for your OpenScript application */ -import { Component, h, router, broker, ojs } from 'openscriptjs'; -import App from './components/App.js'; +// this must come first to ensure that +// all events the system needs have been +// registered before any component is +// initialized +import { configureApp } from "./ojs.config.js"; +import { app } from "openscriptjs"; +import { setupContexts } from "./contexts.js"; +import { setupRoutes } from "./routes.js"; +import "./style.scss"; // Import Bootstrap styles +configureApp(); +setupContexts(); +setupRoutes(); -// Render the app -h.App({ - parent: document.getElementById('app'), - resetParent: true -}); +// start the app +app("router").listen(); -// Start the router -router.listen(); - -console.log('✓ OpenScript app initialized'); +console.log("✓ OpenScript app initialized"); diff --git a/templates/bootstrap/src/ojs.config.js b/templates/bootstrap/src/ojs.config.js new file mode 100644 index 0000000..a578137 --- /dev/null +++ b/templates/bootstrap/src/ojs.config.js @@ -0,0 +1,85 @@ +import { app } from "openscriptjs"; +import { appEvents } from "./events.js"; + +/*---------------------------------- + | Configure the OpenScript App + |---------------------------------- +*/ + +const router = app("router"); +const broker = app("broker"); + +export function configureApp() { + /*----------------------------------- + | Set the global runtime prefix. + | This prefix will be appended + | to every path before resolution. + | So ensure when defining routes, + | you have it as the main prefix. + |------------------------------------ +*/ + router.runtimePrefix(""); + + /**---------------------------------- + * + * Set the default route path here + * ---------------------------------- + */ + router.basePath(""); + + /*-------------------------------- + | Set the logs clearing interval + | for the broker to remove stale + | events. (milliseconds) + |-------------------------------- +*/ + broker.CLEAR_LOGS_AFTER = 30000; + + /*-------------------------------- + | Set how old an event must be + | to be deleted from the broker's + | event log during logs clearing + |-------------------------------- +*/ + broker.TIME_TO_GC = 10000; + + /*------------------------------------------- + | Start the garbage + | collector for the broker + |------------------------------------------- +*/ + broker.removeStaleEvents(); + + /*------------------------------------------ + | Should the broker display events + | in the console as they are fired + |------------------------------------------ +*/ + if (/^(127\.0\.0\.1|localhost|.*\.test)$/.test(router.url().hostname)) { + broker.withLogs(false); + } + + /** + * --------------------------------------------- + * Should the broker require events registration. + * This ensures that only registered events + * can be listened to and fire by the broker. + * --------------------------------------------- + */ + broker.requireEventsRegistration(false); + + /** + * --------------------------------------------- + * Register events with the broker + * --------------------------------------------- + */ + + broker.registerEvents(appEvents); + + /** + * --------------------------------------------- + * Register core services in IoC container + * --------------------------------------------- + */ + app().value("appEvents", appEvents); +} diff --git a/templates/bootstrap/src/routes.js b/templates/bootstrap/src/routes.js new file mode 100644 index 0000000..12aeecd --- /dev/null +++ b/templates/bootstrap/src/routes.js @@ -0,0 +1,32 @@ +/** + * Routes for Bootstrap App + * Defines application routing using OpenScript router + */ + +import { router, h, dom } from "openscriptjs"; +import { gc } from "./contexts.js"; + +export function setupRoutes() { + // Default route - redirect to home + router.default(() => router.to("home")); + + /** + * Helper to render a component to the root element + * @param {Component} component - Component to render + */ + const app = (component) => { + return h.App(component, { + parent: gc.rootElement, + resetParent: true, // Clear parent before rendering + }); + }; + + router.on( + "/", + () => { + console.log("Route: Home"); + app(h.Counter()); + }, + "home" + ); +} diff --git a/templates/tailwind/index.html b/templates/tailwind/index.html index 9b0de1c..2448839 100644 --- a/templates/tailwind/index.html +++ b/templates/tailwind/index.html @@ -8,8 +8,8 @@ -
- +
+ \ No newline at end of file diff --git a/templates/tailwind/src/contexts.js b/templates/tailwind/src/contexts.js new file mode 100644 index 0000000..e3141d5 --- /dev/null +++ b/templates/tailwind/src/contexts.js @@ -0,0 +1,29 @@ +/** + * Context and State Initialization for the App + * Using IoC Container for dependency injection + */ + +import { Context, context, dom, putContext, app } from "openscriptjs"; + +putContext(["global"], "AppContext"); + +/** + * Global Context - Application-wide state + * @type {Context} + */ +export const gc = context("global"); + +export function setupContexts() { + gc.states({ + appName: "Tailwind App", + version: "1.0.0", + isInitialized: false, + }); + + // Set root element for global context + gc.rootElement = dom.id("app-root"); + + // Register context in IoC container + app().value("gc", gc); + app().value("globalContext", gc); +} diff --git a/templates/tailwind/src/events.js b/templates/tailwind/src/events.js new file mode 100644 index 0000000..65c1f11 --- /dev/null +++ b/templates/tailwind/src/events.js @@ -0,0 +1,11 @@ +/** + * Application Events + * Structure: Nested object where keys become namespaced event names + * Example: app.started becomes "app:started" + */ +export const appEvents = { + app: { + started: true, + ready: true, + }, +}; diff --git a/templates/tailwind/src/main.js b/templates/tailwind/src/main.js index 243d08a..7fd9e61 100644 --- a/templates/tailwind/src/main.js +++ b/templates/tailwind/src/main.js @@ -1,19 +1,26 @@ +/** +/** + * Main entry point for your OpenScript application +/** /** * Main entry point for your OpenScript application */ -import { Component, h, router, broker } from 'openscriptjs'; -import App from './components/App.js'; -import './style.css'; // Import Tailwind styles - +// this must come first to ensure that +// all events the system needs have been +// registered before any component is +// initialized +import { configureApp } from "./ojs.config.js"; +import { app } from "openscriptjs"; +import { setupContexts } from "./contexts.js"; +import { setupRoutes } from "./routes.js"; +import "./style.css"; // Import Tailwind styles -// Render the app -h.App({ - parent: document.getElementById('app'), - resetParent: true -}); +configureApp(); +setupContexts(); +setupRoutes(); -// Start the router -router.listen(); +// start the app +app("router").listen(); -console.log('✓ OpenScript app initialized'); +console.log("✓ OpenScript app initialized"); diff --git a/templates/tailwind/src/ojs.config.js b/templates/tailwind/src/ojs.config.js new file mode 100644 index 0000000..a578137 --- /dev/null +++ b/templates/tailwind/src/ojs.config.js @@ -0,0 +1,85 @@ +import { app } from "openscriptjs"; +import { appEvents } from "./events.js"; + +/*---------------------------------- + | Configure the OpenScript App + |---------------------------------- +*/ + +const router = app("router"); +const broker = app("broker"); + +export function configureApp() { + /*----------------------------------- + | Set the global runtime prefix. + | This prefix will be appended + | to every path before resolution. + | So ensure when defining routes, + | you have it as the main prefix. + |------------------------------------ +*/ + router.runtimePrefix(""); + + /**---------------------------------- + * + * Set the default route path here + * ---------------------------------- + */ + router.basePath(""); + + /*-------------------------------- + | Set the logs clearing interval + | for the broker to remove stale + | events. (milliseconds) + |-------------------------------- +*/ + broker.CLEAR_LOGS_AFTER = 30000; + + /*-------------------------------- + | Set how old an event must be + | to be deleted from the broker's + | event log during logs clearing + |-------------------------------- +*/ + broker.TIME_TO_GC = 10000; + + /*------------------------------------------- + | Start the garbage + | collector for the broker + |------------------------------------------- +*/ + broker.removeStaleEvents(); + + /*------------------------------------------ + | Should the broker display events + | in the console as they are fired + |------------------------------------------ +*/ + if (/^(127\.0\.0\.1|localhost|.*\.test)$/.test(router.url().hostname)) { + broker.withLogs(false); + } + + /** + * --------------------------------------------- + * Should the broker require events registration. + * This ensures that only registered events + * can be listened to and fire by the broker. + * --------------------------------------------- + */ + broker.requireEventsRegistration(false); + + /** + * --------------------------------------------- + * Register events with the broker + * --------------------------------------------- + */ + + broker.registerEvents(appEvents); + + /** + * --------------------------------------------- + * Register core services in IoC container + * --------------------------------------------- + */ + app().value("appEvents", appEvents); +} diff --git a/templates/tailwind/src/routes.js b/templates/tailwind/src/routes.js new file mode 100644 index 0000000..4b8b573 --- /dev/null +++ b/templates/tailwind/src/routes.js @@ -0,0 +1,32 @@ +/** + * Routes for Tailwind App + * Defines application routing using OpenScript router + */ + +import { router, h, dom } from "openscriptjs"; +import { gc } from "./contexts.js"; + +export function setupRoutes() { + // Default route - redirect to home + router.default(() => router.to("home")); + + /** + * Helper to render a component to the root element + * @param {Component} component - Component to render + */ + const app = (component) => { + return h.App(component, { + parent: gc.rootElement, + resetParent: true, // Clear parent before rendering + }); + }; + + router.on( + "/", + () => { + console.log("Route: Home"); + app(h.Counter()); + }, + "home" + ); +} diff --git a/test/Component.test.js b/test/Component.test.js index a1b98e2..b7a7025 100644 --- a/test/Component.test.js +++ b/test/Component.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { Component } from "../src/component/Component.js"; +import Component from "../src/component/Component.js"; import { h } from "../src/component/h.js"; import State from "../src/core/State.js"; @@ -25,7 +25,6 @@ describe("Component", () => { const component = new MyComponent(); expect(component.rendered).toBe(false); - expect(component.parentElement).toBeNull(); }); }); @@ -61,7 +60,7 @@ describe("Component", () => { }); describe("Component Mounting", () => { - it("should mount component to parent element", () => { + it("should render component in parent element", () => { const parent = document.createElement("div"); class MyComponent extends Component { @@ -71,12 +70,13 @@ describe("Component", () => { } const component = new MyComponent(); - component.mount(parent); + component.mount(); + + h.MyComponent({ parent }); expect(parent.children.length).toBe(1); expect(parent.textContent).toBe("Mounted Content"); expect(component.rendered).toBe(true); - expect(component.parentElement).toBe(parent); }); }); @@ -101,12 +101,12 @@ describe("Component", () => { }); describe("Component Lifecycle", () => { - it("should call onCreate hook", () => { - let createCalled = false; + it("should handle rendered event", () => { + let rendered = false; - class MyComponent extends Component { - onCreate() { - createCalled = true; + class RenderEventComponent extends Component { + $_rendered() { + rendered = true; } render() { @@ -114,17 +114,20 @@ describe("Component", () => { } } - const component = new MyComponent(); - expect(createCalled).toBe(true); + const component = new RenderEventComponent(); + component.mount(); + h.RenderEventComponent(); + + expect(rendered).toBe(true); }); - it("should call onMount hook when mounted", () => { + it("should handle mounted event", async () => { const parent = document.createElement("div"); - let mountCalled = false; + let mounted = false; - class MyComponent extends Component { - onMount() { - mountCalled = true; + class MountEventComponent extends Component { + $_mounted() { + mounted = true; } render() { @@ -132,32 +135,36 @@ describe("Component", () => { } } - const component = new MyComponent(); - component.mount(parent); + + const component = new MountEventComponent(); + await component.mount(); + h.MountEventComponent(); - expect(mountCalled).toBe(true); + expect(mounted).toBe(true); }); }); describe("Component Update", () => { - it("should update component when update is called", () => { + it("should update component when render called", () => { const parent = document.createElement("div"); let renderCount = 0; - class MyComponent extends Component { + class RenderCountComponent extends Component { render() { renderCount++; return h.div(`Render #${renderCount}`); } } - const component = new MyComponent(); - component.mount(parent); + const component = new RenderCountComponent(); + component.mount(); + + h.RenderCountComponent({ parent }); expect(renderCount).toBe(1); expect(parent.textContent).toBe("Render #1"); - component.update(); + h.RenderCountComponent({ parent, resetParent: true }); expect(renderCount).toBe(2); expect(parent.textContent).toBe("Render #2"); diff --git a/test/Context.test.js b/test/Context.test.js index 24cbf9a..8d309ea 100644 --- a/test/Context.test.js +++ b/test/Context.test.js @@ -1,8 +1,23 @@ -import { describe, it, expect } from "vitest"; -import { Context, putContext, context } from "../src/core/Context.js"; -import { State } from "../src/core/State.js"; +import { describe, it, expect, beforeEach } from "vitest"; +import Context from "../src/core/Context.js"; +import { putContext, context, container } from "../src/index.js"; +import State from "../src/core/State.js"; describe("Context", () => { + beforeEach(() => { + // Clear context map to ensure fresh state for each test + const contextProvider = container.resolve("contextProvider"); + if (contextProvider && contextProvider.map) { + contextProvider.map.clear(); + } + // Suppress console.warn for putContext warnings + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + describe("Context Creation", () => { it("should create context with putContext", () => { putContext("test", "TestContext"); diff --git a/test/MarkupEngine.test.js b/test/MarkupEngine.test.js index d757682..df7b40e 100644 --- a/test/MarkupEngine.test.js +++ b/test/MarkupEngine.test.js @@ -56,7 +56,7 @@ describe("Markup Engine (h)", () => { }); it("should set style object", () => { - const element = h.div({ style: { color: "red", fontSize: "16px" } }); + const element = h.div({ style: "color: red; font-size: 16px;" }); expect(element.style.color).toBe("red"); expect(element.style.fontSize).toBe("16px"); @@ -68,8 +68,10 @@ describe("Markup Engine (h)", () => { let clicked = false; const element = h.button( { - onclick: () => { - clicked = true; + listeners: { + click: () => { + clicked = true; + }, }, }, "Click" @@ -84,11 +86,13 @@ describe("Markup Engine (h)", () => { let hoverCount = 0; const element = h.div({ - onclick: () => { - clickCount++; - }, - onmouseover: () => { - hoverCount++; + listeners: { + click: () => { + clickCount++; + }, + mouseover: () => { + hoverCount++; + }, }, }); @@ -104,14 +108,15 @@ describe("Markup Engine (h)", () => { it("should create fragment with h.$()", () => { const fragment = h.$(h.div("First"), h.div("Second")); - expect(fragment.nodeType).toBe(11); // DOCUMENT_FRAGMENT_NODE + + expect(fragment.tagName).toBe("OJS-SPECIAL-FRAGMENT"); expect(fragment.childNodes.length).toBe(2); }); it("should create fragment with h._()", () => { const fragment = h._(h.span("A"), h.span("B")); - expect(fragment.nodeType).toBe(11); + expect(fragment.tagName).toBe("OJS-SPECIAL-FRAGMENT"); expect(fragment.childNodes.length).toBe(2); }); }); diff --git a/test/RegistrationGuard.test.js b/test/RegistrationGuard.test.js new file mode 100644 index 0000000..d5a7857 --- /dev/null +++ b/test/RegistrationGuard.test.js @@ -0,0 +1,127 @@ +import { describe, it, expect, vi } from "vitest"; +import Component from "../src/component/Component.js"; +import Mediator from "../src/mediator/Mediator.js"; +import Listener from "../src/broker/Listener.js"; +import { app } from "../src/index.js"; + +console.log("Running RegistrationGuard.test.js"); + +describe("Registration Guards", () => { + describe("Component Registration Guard", () => { + it("should allow initial registration", async () => { + class TestComponent extends Component { + render() { + return "
Test
"; + } + } + + const instance = new TestComponent(); + expect(instance.__ojsRegistered).toBeUndefined(); + + await instance.mount(); + + expect(instance.__ojsRegistered).toBe(true); + }); + + it("should prevent duplicate component registration", async () => { + class TestComponent2 extends Component { + render() { + return "
Test2
"; + } + } + + const instance = new TestComponent2(); + + // First registration + await instance.mount(); + expect(instance.__ojsRegistered).toBe(true); + + // Attempt duplicate registration + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + await instance.mount(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("already registered") + ); + consoleWarnSpy.mockRestore(); + }); + }); + + describe("Mediator Registration Guard", () => { + it("should allow initial registration", async () => { + class TestMediator extends Mediator {} + + const instance = new TestMediator(); + expect(instance.__ojsRegistered).toBeUndefined(); + + await instance.register(); + + expect(instance.__ojsRegistered).toBe(true); + }); + + it("should prevent duplicate mediator registration", async () => { + class TestMediator2 extends Mediator {} + + const instance = new TestMediator2(); + + // First registration + await instance.register(); + expect(instance.__ojsRegistered).toBe(true); + + // Attempt duplicate registration + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + await instance.register(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("already registered") + ); + consoleWarnSpy.mockRestore(); + }); + }); + + describe("Listener Registration Guard", () => { + it("should allow initial registration", async () => { + class TestListener extends Listener { + $$testEvent() { + // Event handler + } + } + + const instance = new TestListener(); + expect(instance.__ojsRegistered).toBeUndefined(); + + await instance.register(); + + expect(instance.__ojsRegistered).toBe(true); + }); + + it("should prevent duplicate listener registration", async () => { + class TestListener2 extends Listener { + $$anotherEvent() { + // Event handler + } + } + + const instance = new TestListener2(); + + // First registration + await instance.register(); + expect(instance.__ojsRegistered).toBe(true); + + // Attempt duplicate registration + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + await instance.register(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("already registered") + ); + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/test/RunnerSingleton.test.js b/test/RunnerSingleton.test.js new file mode 100644 index 0000000..6e9035c --- /dev/null +++ b/test/RunnerSingleton.test.js @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import Runner from "../src/core/Runner.js"; +import Component from "../src/component/Component.js"; +import Mediator from "../src/mediator/Mediator.js"; +import Listener from "../src/broker/Listener.js"; +import { container } from "../src/core/Container.js"; +import { app } from "../src/index.js"; + +describe("Runner with IoC Container Singletons", () => { + let runner; + + beforeEach(() => { + runner = new Runner(); + }); + + describe("Component Singletons", () => { + it("should store Component instances in container as singletons", async () => { + class TestComponent extends Component { + render() { + return "
Test
"; + } + } + + await runner.run(TestComponent); + const classKey = runner.getClassKey(TestComponent); + const instance = container.resolve(classKey); + + expect(instance).toBeDefined(); + expect(instance).toBeInstanceOf(TestComponent); + }); + + it("should reuse singleton instances from container", async () => { + class TestComponent extends Component { + render() { + return "
Test
"; + } + } + + // First run + await runner.run(TestComponent); + const classKey = runner.getClassKey(TestComponent); + const firstInstance = container.resolve(classKey); + + // Second run - should retrieve same instance from container + await runner.run(TestComponent); + const secondInstance = container.resolve(classKey); + + expect(secondInstance).toBe(firstInstance); + }); + + it("should NOT store functional components in container", async () => { + const functionalComponent = function MyFunc() { + return "
Functional
"; + }; + + await runner.run(functionalComponent); + const classKey = runner.getClassKey(functionalComponent); + + // Functional components are not singletons + expect(container.has(classKey)).toBe(false); + }); + }); + + describe("Mediator Singletons", () => { + it("should store Mediator instances in container", async () => { + class TestMediator extends Mediator {} + + await runner.run(TestMediator); + const classKey = runner.getClassKey(TestMediator); + const instance = container.resolve(classKey); + + expect(instance).toBeDefined(); + expect(instance).toBeInstanceOf(TestMediator); + }); + }); + + describe("Listener Singletons", () => { + it("should store Listener instances in container", async () => { + class TestListener extends Listener { + $$testEvent() {} + } + + await runner.run(TestListener); + const classKey = runner.getClassKey(TestListener); + const instance = container.resolve(classKey); + + expect(instance).toBeDefined(); + expect(instance).toBeInstanceOf(TestListener); + }); + }); + + describe("Registration Guard Integration", () => { + it("should skip re-registration using __ojsRegistered flag", async () => { + class TestComponent extends Component { + render() { + return "
Test
"; + } + } + + // First run - registers the component + await runner.run(TestComponent); + const classKey = runner.getClassKey(TestComponent); + const instance = container.resolve(classKey); + + expect(instance.__ojsRegistered).toBe(true); + + // Second run - should skip because already registered + const mountSpy = vi.spyOn(instance, "mount"); + await runner.run(TestComponent); + + expect(mountSpy).not.toHaveBeenCalled(); + mountSpy.mockRestore(); + }); + }); +}); diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000000000000000000000000000000000000..2d788dd71d2ed1c9c8f4470689c0307e7070feb9 GIT binary patch literal 16318 zcmeI3>uy^`5XV>QHzb~*Uk+6Tsem}Kle7r}QF=i|y^$7#)D}pSGz6N9V)rH}FVROp zJOy8QBt8TG-;Sqqj(wciPCy79Mb0_i-PyV9>|A!%fBpGpdLw<5inN#Z(`MRD-L#$# z(zd?!()arIahgxFX)b>XwQZ`cmsT|9iAFp0Q$cuNQR<_g&*^ke^X%m3LR!*Wkyg_e z>876aEu`gsZ#%VfFRh(C@~L`_f_b&r_|EI`%c5;3T@ppDbVt;!i<8~7k=8`T3$@+q z^L0n-qF)cytY0Q)KIFEb3mV2WBx||Eg4TY~x{gVF3v>PoU8|dh1UgR}9A36$MK-lQtkh&&EltVOd|Di9jdn%TMwazv zdah?7D_LY~c2V>e*=pWTxAR`#(*HGSob_K7XK>X`_r;aXf;ZsXzDCXJe^)kVMfL|x z!$Pj7@A6u&$V&75UA6P~wmxTN>2IaW(tvOD`AQ#ayPa;RRMLU?sq0j04UV4z;({oi2_#tSxqV>Tu;dQswW}ek4K$2Ipw|Xo(KiAuuRwuAt z5iacYfq2-^*uZBielv5rot_GV*_9o&;3WgnDCEC+47~NLQ@^L*j`$z`<#gt!68<0D=C8slcBEtD(E+s48x+yz94+LT=yR$rN?OeH+@2U>+e zD+L8W2M@vdc(Va*vhp~mjxSqHUxU!SkO2FKDDFVIx}A4~$@7eC%Ng}yi?Cb=B_0`V zy2XC8@fHOVh0(?ca9E_~Qnu(aBC=PfewDTdEjAPTg_Vz)W;4F7+8^f~RY=mrl`VbP zgT$9WfoBjB8@kfRLA*C7@3f+4r-aui1=?dzw*&N8ybEMI{UmOKFCP{BElQ_M66ivC zWH-yCDr4*K2l?9ika*UjKIy7da4*4W^fen2F?6iqN+^ijO2$;+DsPr zb~2U^N?3q(zL_he(-})T*wEm{R@@?U!}V6Fx5^Qg{KrgtJ^qCe_!(VYA*71+epbRY zI0hZA_8Zm6v$~H$$DI#$^|wR2>P!in%|%vrRy?^Kx0!qeV$91SNHDNF;cNOG`fX|6 z$=2Iepnf;_>bTr_8ZY>PuS+;b>(b(gwS0NV`Y#Q@_?nxB9%glht;xlV=ZW^lo;=o` z_5@KKc}>-xT#zkk%bLJncr=T~+?LQcJ`Gq;a6K0;I2gt^jLk7Q$6@u+lW}4S#sGa( z>@j*SppTAS%J9SmElKTxD0JvSIo5Mlo@La#X&msryU;#bEiNf^7P6b;XXNd%Qta$* zUuze#Tx_1|8jlwznN))szS}pahbOIH%F=rjP{@m{DSFrvg?L|1VL1IE-Y?S&9C~T7 zC^U1%5^*q=jw~eRwNoV1t-I>QyA+~e`!z1ZjO{ICtBHH<9b9C`-At#&wlQ;8`f3q& z*-rygM|YKGb{RCR=qTu^J6_#0;?eP;gD6LY(T%+{Z+Jh3H?VHoXGxU}|<9Kvr_b}^= zoi#Nv9ukWvrit;)8o3v*N~ekOni$VD!=G5B|9*^DK3~T|ldnU$=j_CGcofg$@lZGP z9S_QBT;!Z?J%MIaE7i?ip0^sqEv55{8iAhITi9#fBA1ALcUa_FhxsV3IGZBF>&lz2 z$yeewS7!Z z?q+HC`X_-_VE}_y87vFGtncfJwk)#QP~KrDN{oU7;xu0;pgCu#)+_83q}n!^u~5GP zdRc39;h20ePx*A79YnMjbjJBibsRea=s1~K>}$<&Y4S*yQ#oisc3>K(b`aFGrNv}+ zbCM9y9#c)TNBuPa8?BbtK$(*r*iyB(T*+pQ14u4#*5Sy9ijv>0I_SW>YrqQLe{4)DDG?XE_fg2WK8@2|Q*~b~A>peohyD zvMTpcSyb#>-aK<^GPyWo4+(`t?be9t6B%lAT5o66&1pT6DktI1X?=5AKc~o}z7p`y zoYvRn1lX*@@`5IlYce_F`Qv7CRGA0!D|B!249>GRpyS5bxEY*hZ$NvrH|)WWt~vo9 zd6J!h{68|d#hec{8Jyj@Wt~ql-)u5C{64u=lfg9^T-4i}T3q$4ySZ(|DR(u}1&J`&R$Y3=Y}Xaduc<8kG{D zk6*8{8i4lgViYF^J*Q+ zsf^t)KFueW$xxw<-{njzM?ZT%UM=c^C~0a@|5W9y$z+;LhTjIAL@ml}Enat29colN zR&P|_(d|H=?9E-{qqL9Z#t44N$NiOVt|)Gl{9JW5kDq3A+rjU+J6|>ue&WwLqskdG*?W}&PM9X8 z0hptyxbnJYd^i=*(-93(q4i&VRPQTS*A8CqXzjRX!U}SZQRQ`BvRVV^=o-@M2Z=HX5JS){Kni`IVpXt zKfBjkNUNBAx!>zP7D `openscript.${format}.js`, }, - - build: { - lib: { - // Entry point for the library - entry: resolve(__dirname, 'src/index.js'), - name: 'OpenScript', - // Output formats - formats: ['es', 'umd'], - fileName: (format) => `openscript.${format}.js` - }, - - rollupOptions: { - // Preserve module structure - output: { - // Preserve original names where possible - preserveModules: false, - // Ensure component names are kept in comments - banner: '/* OpenScript Framework - Built with component name preservation */', - } - }, - - // Source maps for debugging - sourcemap: true, - - // Target modern browsers - target: 'es2020', - - minify: 'terser', - terserOptions: { - // Preserve class names - keep_classnames: true, - keep_fnames: true, - mangle: { - // Don't mangle properties that start with these patterns - reserved: ['Component', 'State', 'Mediator', 'Broker'] - } - } + + rollupOptions: { + // Preserve module structure + output: { + // Use named exports only (no default export) + exports: "named", + // Preserve original names where possible + preserveModules: false, + // Ensure component names are kept in comments + banner: + "/* OpenScript Framework - Built with component name preservation */", + }, + }, + + // Source maps for debugging + sourcemap: true, + + // Target modern browsers + target: "es2020", + + minify: "terser", + terserOptions: { + // Preserve class names + keep_classnames: true, + keep_fnames: true, + mangle: { + // Don't mangle properties that start with these patterns + reserved: ["Component", "State", "Mediator", "Broker"], + }, + }, + }, + + resolve: { + alias: { + "@": resolve(__dirname, "./"), }, - - resolve: { - alias: { - '@': resolve(__dirname, './'), - } - } + }, }); From 7320d6862825e899e9e763e9a3c350fbdbd77531 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 00:22:09 +0300 Subject: [PATCH 03/46] initiated modular openscript --- examples/advanced-features.js | 97 +++--- examples/basic-usage.js | 33 +- examples/component-example.js | 38 +-- examples/context-state-example.js | 2 +- examples/event-handling.js | 216 ++++++------- examples/full-application.js | 491 +++++++++++++++--------------- package.json | 6 +- 7 files changed, 451 insertions(+), 432 deletions(-) diff --git a/examples/advanced-features.js b/examples/advanced-features.js index a405a8c..4adacd7 100644 --- a/examples/advanced-features.js +++ b/examples/advanced-features.js @@ -1,46 +1,43 @@ -import { Component, h, state, putContext, context } from "../index.js"; +import { Component, h, state, putContext, context, app } from "../index.js"; // 1. Fragments Example class FragmentComponent extends Component { - render(...args) { - // h.$ or h._ creates a document fragment - // This allows returning multiple elements without a parent wrapper - return h.$( - h.h3("Fragment Header"), - h.p("This content is inside a fragment."), - h.p("No extra div wrapper is added to the DOM.") - ); - } + render(...args) { + // h.$ or h._ creates a document fragment + // This allows returning multiple elements without a parent wrapper + return h.$( + h.h3("Fragment Header"), + h.p("This content is inside a fragment."), + h.p("No extra div wrapper is added to the DOM.") + ); + } } // 2. State Management Example const counter = state(0); class CounterComponent extends Component { - render(...args) { - // Pass the state to the component to auto-subscribe - // The component will re-render when 'counter' changes - return h.div( - h.h3(`Count: ${counter.value}`), - h.button( - { onclick: () => counter.value++ }, - "Increment" - ), - ...args - ); - } + render(...args) { + // Pass the state to the component to auto-subscribe + // The component will re-render when 'counter' changes + return h.div( + h.h3(`Count: ${counter.value}`), + h.button({ onclick: () => counter.value++ }, "Increment"), + ...args + ); + } } // 3. Context Example // Define a context (normally this would be in a separate file) class ThemeContext { - constructor() { - this.theme = state("light"); - } + constructor() { + this.theme = state("light"); + } - toggle() { - this.theme.value = this.theme.value === "light" ? "dark" : "light"; - } + toggle() { + this.theme.value = this.theme.value === "light" ? "dark" : "light"; + } } // Register the context @@ -48,30 +45,32 @@ class ThemeContext { // Since we are not loading from a file here, we just register it manually for this example // In a real app, you might use: putContext("Theme", "contexts.ThemeContext") const themeCtx = new ThemeContext(); -context("Theme", themeCtx); // Manually putting it in the provider for this example +// Manually putting it in the provider for this example using the IoC container +app("contextProvider").map.set("Theme", themeCtx); class ThemedComponent extends Component { - constructor() { - super(); - // Access the context - this.themeContext = context("Theme"); - } + constructor() { + super(); + // Access the context + this.themeContext = context("Theme"); + } - render(...args) { - const currentTheme = this.themeContext.theme.value; - - return h.div( - { - style: `background-color: ${currentTheme === 'light' ? '#fff' : '#333'}; color: ${currentTheme === 'light' ? '#000' : '#fff'}; padding: 20px;` - }, - h.h3(`Current Theme: ${currentTheme}`), - h.button( - { onclick: () => this.themeContext.toggle() }, - "Toggle Theme" - ), - ...args - ); - } + render(...args) { + const currentTheme = this.themeContext.theme.value; + + return h.div( + { + style: `background-color: ${ + currentTheme === "light" ? "#fff" : "#333" + }; color: ${ + currentTheme === "light" ? "#000" : "#fff" + }; padding: 20px;`, + }, + h.h3(`Current Theme: ${currentTheme}`), + h.button({ onclick: () => this.themeContext.toggle() }, "Toggle Theme"), + ...args + ); + } } export { FragmentComponent, CounterComponent, ThemedComponent }; diff --git a/examples/basic-usage.js b/examples/basic-usage.js index f7239e3..7ca024a 100644 --- a/examples/basic-usage.js +++ b/examples/basic-usage.js @@ -1,23 +1,28 @@ -import { Runner, Component, h, State } from "../index.js"; +import { app, State, Component, ojs } from "openscriptjs"; // Define a State const counter = State.state(0); +const h = app("h"); // Define a Component class CounterComponent extends Component { - render(...args) { - return h.div( - h.h1(`Counter: ${counter.value}`), - h.button( - { - onclick: () => counter.value++, - }, - "Increment" - ), - ...args - ); - } + render(counter, ...args) { + return h.div( + h.h1(`Counter: ${counter.value}`), + h.button( + { + onclick: this.method("increment"), + }, + "Increment" + ), + ...args + ); + } + + increment() { + counter.value++; + } } // Mount the Component -new Runner().run(CounterComponent); +ojs(CounterComponent); diff --git a/examples/component-example.js b/examples/component-example.js index 67cf95c..3c2bb16 100644 --- a/examples/component-example.js +++ b/examples/component-example.js @@ -1,26 +1,26 @@ -import { Component, h, broker } from "../index.js"; +import { Component, h, app } from "../index.js"; class SenderComponent extends Component { - render(...args) { - return h.button( - { - onclick: () => { - broker.emit("message", "Hello from Sender!"); - }, - }, - "Send Message", - ...args - ); - } + render(...args) { + return h.button( + { + onclick: () => { + app("broker").emit("message", "Hello from Sender!"); + }, + }, + "Send Message", + ...args + ); + } } class ReceiverComponent extends Component { - constructor() { - super(); - this.message = "Waiting..."; - } + constructor() { + super(); + this.message = "Waiting..."; + } - render(...args) { - return h.div(`Received: ${this.message}`, ...args); - } + render(...args) { + return h.div(`Received: ${this.message}`, ...args); + } } diff --git a/examples/context-state-example.js b/examples/context-state-example.js index e33eb3a..36c7b45 100644 --- a/examples/context-state-example.js +++ b/examples/context-state-example.js @@ -3,7 +3,7 @@ * Demonstrates best practice: defining states in contexts and passing to components */ -import { Component, h, context, putContext, state, dom } from "../index.js"; +import { Component, h, context, putContext, state, dom } from "openscriptjs"; // ============================================ // 1. INITIALIZE CONTEXTS AND STATES diff --git a/examples/event-handling.js b/examples/event-handling.js index 6b7e396..e707987 100644 --- a/examples/event-handling.js +++ b/examples/event-handling.js @@ -1,135 +1,145 @@ -import { Mediator, Component, h, broker, payload, Utils } from "../index.js"; +import { Mediator, Component, h, app, payload, Utils } from "../index.js"; // 1. Declarative Event Listening (Mediator) // Mediators are perfect for handling business logic and responding to events. class AuthMediator extends Mediator { - // The '$$' prefix tells the BrokerRegistrar to register these as event listeners. - // Nested objects create namespaced events. - $$user = { - // Listens to 'user:login' - // Listeners receive 'ed' (EventData string) and 'event' (Event Name) - login: (ed, event) => { - const data = Utils.parsePayload(ed); - console.log("User logged in:", data.message); - - // Respond by emitting another event - broker.send("user:authenticated", payload({ user: data.message.username })); - }, - - // Listens to 'user:logout' - logout: (ed, event) => { - console.log("User logged out"); - } - }; - - $$system = { - // Listens to 'system:boot' - boot: (ed, event) => { - console.log("System booted"); - } - }; + // The '$$' prefix tells the BrokerRegistrar to register these as event listeners. + // Nested objects create namespaced events. + $$user = { + // Listens to 'user:login' + // Listeners receive 'ed' (EventData string) and 'event' (Event Name) + login: (ed, event) => { + const data = Utils.parsePayload(ed); + console.log("User logged in:", data.message); + + // Respond by emitting another event + app("broker").send( + "user:authenticated", + payload({ user: data.message.username }) + ); + }, + + // Listens to 'user:logout' + logout: (ed, event) => { + console.log("User logged out"); + }, + }; + + $$system = { + // Listens to 'system:boot' + boot: (ed, event) => { + console.log("System booted"); + }, + }; } // 2. Advanced Declarative Listening class AdvancedMediator extends Mediator { - $$user = { - // Listen to multiple events separated by underscore - // This will trigger on 'user:login' OR 'user:logout' - login_logout: (ed, event) => { - console.log(`User event triggered: ${event}`); - } - }; + $$user = { + // Listen to multiple events separated by underscore + // This will trigger on 'user:login' OR 'user:logout' + login_logout: (ed, event) => { + console.log(`User event triggered: ${event}`); + }, + }; } // 3. Component Triggering Events & Listening class LoginButton extends Component { - // Define a method to handle component events - // The '$_' prefix allows this method to be used as an event listener in the markup - $_onClick(e) { - broker.send("user:login", payload({ username: "Alice" })); - } - - render(...args) { - return h.button( - { - // Use the defined method as a listener - onclick: this.$_onClick - }, - "Login", - ...args - ); - } + // Define a method to handle component events + // The '$_' prefix allows this method to be used as an event listener in the markup + $_onClick(e) { + app("broker").send("user:login", payload({ username: "Alice" })); + } + + render(...args) { + return h.button( + { + // Use the defined method as a listener + onclick: this.$_onClick, + }, + "Login", + ...args + ); + } } // 4. Listening to Component Events class UserDashboard extends Component { - render(...args) { - return h.div( - h.h3("Dashboard"), - // Listen to the 'rendered' event of the LoginButton component - // Syntax: h.on(ComponentClass, eventName, callback) - h.on(LoginButton, "rendered", () => { - console.log("Login Button has been rendered!"); - }), - h.component(new LoginButton()), - ...args - ); - } + render(...args) { + return h.div( + h.h3("Dashboard"), + // Listen to the 'rendered' event of the LoginButton component + // Syntax: h.on(ComponentClass, eventName, callback) + h.on(LoginButton, "rendered", () => { + console.log("Login Button has been rendered!"); + }), + h.component(new LoginButton()), + ...args + ); + } } // 5. State Management in Components import { state } from "../index.js"; class Counter extends Component { - // Create state inside the component - count = state(0); - - $_increment() { - this.count.value++; - } - - // Components automatically listen to state changes when state is passed to render - render(...args) { - return h.div( - h.p(`Count: ${this.count.value}`), - h.button({ onclick: this.$_increment }, "Increment"), - ...args - ); - } + // Create state inside the component + count = state(0); + + $_increment() { + this.count.value++; + } + + // Components automatically listen to state changes when state is passed to render + render(...args) { + return h.div( + h.p(`Count: ${this.count.value}`), + h.button({ onclick: this.$_increment }, "Increment"), + ...args + ); + } } // 6. Direct State Listeners class StateExample extends Component { - count = state(0); - - constructor() { - super(); - - // Direct listener using state.listener() method - this.count.listener((currentState) => { - console.log(`State changed to: ${currentState.value}`); - }); - } - - render(...args) { - return h.div( - h.p(`Count: ${this.count.value}`), - h.button( - { - onclick: () => this.count.value++ - }, - "Increment" - ), - ...args - ); - } + count = state(0); + + constructor() { + super(); + + // Direct listener using state.listener() method + this.count.listener((currentState) => { + console.log(`State changed to: ${currentState.value}`); + }); + } + + render(...args) { + return h.div( + h.p(`Count: ${this.count.value}`), + h.button( + { + onclick: () => this.count.value++, + }, + "Increment" + ), + ...args + ); + } } // 7. Imperative Event Listening // You can also listen to events directly using the broker instance. -broker.on("user:authenticated", (ed, event) => { - const data = Utils.parsePayload(ed); - console.log("Imperative listener caught authenticated event:", data.message); +app("broker").on("user:authenticated", (ed, event) => { + const data = Utils.parsePayload(ed); + console.log("Imperative listener caught authenticated event:", data.message); }); -export { AuthMediator, AdvancedMediator, LoginButton, UserDashboard, Counter, StateExample }; +export { + AuthMediator, + AdvancedMediator, + LoginButton, + UserDashboard, + Counter, + StateExample, +}; diff --git a/examples/full-application.js b/examples/full-application.js index e6d7119..da4d637 100644 --- a/examples/full-application.js +++ b/examples/full-application.js @@ -5,16 +5,16 @@ */ import { - Component, - Mediator, - h, - state, - broker, - router, - context, - putContext, - payload, - Utils + Component, + Mediator, + h, + state, + app, + ojs, + context, + putContext, + payload, + Utils, } from "../index.js"; // ============================================ @@ -22,39 +22,39 @@ import { // ============================================ // Define all application events in a structured object const $e = { - system: { - booted: true, - needs: { - reload: true, - } + system: { + booted: true, + needs: { + reload: true, }, - user: { - authenticated: true, - loggedOut: true, - needs: { - login: true, - logout: true, - profile: true, - }, - has: { - loginError: true, - } + }, + user: { + authenticated: true, + loggedOut: true, + needs: { + login: true, + logout: true, + profile: true, }, - cart: { - itemAdded: true, - needs: { - addition: true, - removal: true, - allItems: true, - }, - has: { - items: true, - } - } + has: { + loginError: true, + }, + }, + cart: { + itemAdded: true, + needs: { + addition: true, + removal: true, + allItems: true, + }, + has: { + items: true, + }, + }, }; // Register all events with the broker -broker.registerEvents($e); +app("broker").registerEvents($e); // ============================================ // 2. CONTEXT INITIALIZATION @@ -62,273 +62,278 @@ broker.registerEvents($e); // Create application contexts putContext(["global", "user", "page"], "AppContext"); -const gc = context("global"); // Global context -const uc = context("user"); // User context -const pc = context("page"); // Page context +const gc = context("global"); // Global context +const uc = context("user"); // User context +const pc = context("page"); // Page context // Initialize states in contexts gc.states({ - auth: false, - appName: "MyApp", + auth: false, + appName: "MyApp", }); uc.states({ - cart: {}, - profile: null, - isLoggedIn: false, + cart: {}, + profile: null, + isLoggedIn: false, }); pc.states({ - currentPage: "Home", - loading: false, + currentPage: "Home", + loading: false, }); // Add state listeners uc.cart.listener((cartState) => { - console.log(`Cart updated: ${Object.keys(cartState.value).length} items`); + console.log(`Cart updated: ${Object.keys(cartState.value).length} items`); }); // ============================================ // 3. MEDIATOR - BUSINESS LOGIC // ============================================ class CartMediator extends Mediator { - // Listen to multiple events with underscore separation - $$cart = { - needs: { - // Single event listener - addition: (ed, event) => { - this.addToCart(ed, event); - }, - - removal: (ed, event) => { - this.removeFromCart(ed, event); - }, - - allItems: () => { - this.fetchCart(); - } - } - }; - - // Multi-event listener - triggers on user:authenticated OR user:loggedOut - $$user = { - authenticated_loggedOut: (ed, event) => { - console.log(`User status changed: ${event}`); - this.broadcast($e.cart.needs.allItems); - } - }; - - async addToCart(ed, event) { - const data = Utils.parsePayload(ed); - const product = data.message.product; - - // Simulate API call - console.log(`Adding ${product.name} to cart`); - - // Update cart in context - const currentCart = {...uc.cart.value}; - currentCart[product.id] = product; - uc.cart.value = currentCart; - - // Broadcast success - this.send($e.cart.itemAdded, payload({ product })); - } - - async removeFromCart(ed, event) { - const data = Utils.parsePayload(ed); - const cartMap = uc.cart.value; - delete cartMap[data.message.productId]; - uc.cart.value = {...cartMap}; - } - - async fetchCart() { - // Simulate fetching cart from API - console.log("Fetching cart items..."); - } + // Listen to multiple events with underscore separation + $$cart = { + needs: { + // Single event listener + addition: (ed, event) => { + this.addToCart(ed, event); + }, + + removal: (ed, event) => { + this.removeFromCart(ed, event); + }, + + allItems: () => { + this.fetchCart(); + }, + }, + }; + + // Multi-event listener - triggers on user:authenticated OR user:loggedOut + $$user = { + authenticated_loggedOut: (ed, event) => { + console.log(`User status changed: ${event}`); + this.broadcast($e.cart.needs.allItems); + }, + }; + + async addToCart(ed, event) { + const data = Utils.parsePayload(ed); + const product = data.message.product; + + // Simulate API call + console.log(`Adding ${product.name} to cart`); + + // Update cart in context + const currentCart = { ...uc.cart.value }; + currentCart[product.id] = product; + uc.cart.value = currentCart; + + // Broadcast success + this.send($e.cart.itemAdded, payload({ product })); + } + + async removeFromCart(ed, event) { + const data = Utils.parsePayload(ed); + const cartMap = uc.cart.value; + delete cartMap[data.message.productId]; + uc.cart.value = { ...cartMap }; + } + + async fetchCart() { + // Simulate fetching cart from API + console.log("Fetching cart items..."); + } } // ============================================ // 4. COMPONENT - COUNT BUTTON // ============================================ class CounterButton extends Component { - count = state(0); - - // Component method with $_ prefix - $_increment() { - this.count.value++; - } - - render(...args) { - return h.div( - { class: "counter-section" }, - h.p(`Count: ${this.count.value}`), - h.button( - { - class: "btn btn-primary", - onclick: this.$_increment - }, - "Increment" - ), - ...args - ); - } + count = state(0); + + // Component method with $_ prefix + $_increment() { + this.count.value++; + } + + render(...args) { + return h.div( + { class: "counter-section" }, + h.p(`Count: ${this.count.value}`), + h.button( + { + class: "btn btn-primary", + onclick: this.$_increment, + }, + "Increment" + ), + ...args + ); + } } // ============================================ // 5. COMPONENT - PRODUCT CARD // ============================================ class ProductCard extends Component { - // Using h.func to create inline event handlers - render(product, ...args) { - return h.div( - { class: "card" }, - h.div( - { class: "card-body" }, - h.h5({ class: "card-title" }, product.name), - h.p({ class: "card-text" }, `$${product.price}`), - h.button( - { - class: "btn btn-success", - // h.func creates a callable string for inline handlers - onclick: h.func( - "broker.send", - $e.cart.needs.addition, - payload({ product }) - ) - }, - "Add to Cart" - ) + // Using h.func to create inline event handlers + render(product, ...args) { + return h.div( + { class: "card" }, + h.div( + { class: "card-body" }, + h.h5({ class: "card-title" }, product.name), + h.p({ class: "card-text" }, `$${product.price}`), + h.button( + { + class: "btn btn-success", + // h.func creates a callable string for inline handlers + onclick: h.func( + "broker.send", + $e.cart.needs.addition, + payload({ product }) ), - ...args - ); - } + }, + "Add to Cart" + ) + ), + ...args + ); + } } // ============================================ // 6. COMPONENT - SHOPPING CART // ============================================ class ShoppingCart extends Component { - render(...args) { - return h.div( - { class: "cart-container" }, - h.h3("Shopping Cart"), - // Using v() for reactive rendering based on state - h.div( - h.p("Items in cart:"), - h.ul( - // Reactive rendering - automatically updates when uc.cart changes - ...Object.values(uc.cart.value).map(product => - h.li( - product.name, - " - ", - h.button( - { - class: "btn btn-sm btn-danger", - onclick: h.func( - "broker.send", - $e.cart.needs.removal, - payload({ productId: product.id }) - ) - }, - "Remove" - ) - ) - ) - ) - ), - ...args - ); - } + render(...args) { + return h.div( + { class: "cart-container" }, + h.h3("Shopping Cart"), + // Using v() for reactive rendering based on state + h.div( + h.p("Items in cart:"), + h.ul( + // Reactive rendering - automatically updates when uc.cart changes + ...Object.values(uc.cart.value).map((product) => + h.li( + product.name, + " - ", + h.button( + { + class: "btn btn-sm btn-danger", + onclick: h.func( + "broker.send", + $e.cart.needs.removal, + payload({ productId: product.id }) + ), + }, + "Remove" + ) + ) + ) + ) + ), + ...args + ); + } } // ============================================ // 7. COMPONENT - DASHBOARD (Parent Component) // ============================================ class Dashboard extends Component { - render(...args) { - const sampleProducts = [ - { id: 1, name: "Widget", price: 9.99 }, - { id: 2, name: "Gadget", price: 19.99 }, - { id: 3, name: "Doohickey", price: 14.99 } - ]; - - return h.div( - { class: "container mt-4" }, - h.div( - { class: "row" }, - h.div( - { class: "col-md-8" }, - h.h2("Products"), - h.div( - { class: "row" }, - ...sampleProducts.map(product => - h.div( - { class: "col-md-4 mb-3" }, - h.ProductCard(product) - ) - ) - ) - ), - h.div( - { class: "col-md-4" }, - // Render counter button - h.CounterButton(), - h.hr(), - // Render shopping cart - h.ShoppingCart(), - // Listen to ProductCard's 'rendered' event - h.on(ProductCard, "rendered", () => { - console.log("Product card rendered"); - }) - ) - ), - ...args - ); - } + render(...args) { + const sampleProducts = [ + { id: 1, name: "Widget", price: 9.99 }, + { id: 2, name: "Gadget", price: 19.99 }, + { id: 3, name: "Doohickey", price: 14.99 }, + ]; + + return h.div( + { class: "container mt-4" }, + h.div( + { class: "row" }, + h.div( + { class: "col-md-8" }, + h.h2("Products"), + h.div( + { class: "row" }, + ...sampleProducts.map((product) => + h.div({ class: "col-md-4 mb-3" }, h.ProductCard(product)) + ) + ) + ), + h.div( + { class: "col-md-4" }, + // Render counter button + h.CounterButton(), + h.hr(), + // Render shopping cart + h.ShoppingCart(), + // Listen to ProductCard's 'rendered' event + h.on(ProductCard, "rendered", () => { + console.log("Product card rendered"); + }) + ) + ), + ...args + ); + } } // ============================================ // 8. INITIALIZATION // ============================================ function initializeApp() { - // Initialize mediator - const cartMediator = new CartMediator(); - - // Mount the dashboard component to DOM - const root = document.getElementById("app"); - if (root) { - const dashboard = new Dashboard(); - dashboard.mount(root); - - // Broadcast system booted event - broker.broadcast($e.system.booted); - } + // Initialize mediator + // With IoC, we should register it or just instantiate it if it registers itself + const cartMediator = new CartMediator(); + + // Mount the dashboard component to DOM + // ojs() handles mounting to the root element defined in context or default + ojs(Dashboard); + + // Broadcast system booted event + app("broker").broadcast($e.system.booted); } // ============================================ // 9. ROUTE SETUP // ============================================ // Routes use a fluent API with .on(), .prefix(), and .group() -router.on("/", () => { +app("router").on( + "/", + () => { pc.currentPage.value = "Home"; -}, "home"); + }, + "home" +); -router.prefix("products").group(() => { - router.on("/{productId}/view", () => { +app("router") + .prefix("products") + .group(() => { + app("router").on( + "/{productId}/view", + () => { pc.currentPage.value = "Product Details"; // Access params via router.params.productId - }, "product.view"); -}); + }, + "product.view" + ); + }); // Start listening to route changes -router.listen(); +app("router").listen(); // Export for use -export { - Dashboard, - ProductCard, - ShoppingCart, - CounterButton, - CartMediator, - initializeApp +export { + Dashboard, + ProductCard, + ShoppingCart, + CounterButton, + CartMediator, + initializeApp, }; diff --git a/package.json b/package.json index a0009c9..70e0546 100644 --- a/package.json +++ b/package.json @@ -51,12 +51,12 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/yourusername/openscriptjs.git" + "url": "https://github.com/OpenScriptJs/modular-openscript.git" }, "bugs": { - "url": "https://github.com/yourusername/openscriptjs/issues" + "url": "https://github.com/OpenScriptJs/modular-openscript/issues" }, - "homepage": "https://github.com/yourusername/openscriptjs#readme", + "homepage": "https://github.com/OpenScriptJs/modular-openscript#readme", "engines": { "node": ">=16.0.0" }, From a1ca4a2874295f0170d8aff3854f82a7c9af305c Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 00:37:14 +0300 Subject: [PATCH 04/46] initiated modular openscript --- README.md | 766 +++++++++++++----------------------------------------- 1 file changed, 178 insertions(+), 588 deletions(-) diff --git a/README.md b/README.md index 2b7cde6..0edd39d 100644 --- a/README.md +++ b/README.md @@ -1,710 +1,300 @@ # Modular OpenScript Framework -This is a modularized version of the OpenScript framework, designed to be more maintainable, testable, and scalable. +A modern, modular, event-driven JavaScript framework built for scalability and maintainability. OpenScript combines the power of **Inversion of Control (IoC)**, **Reactive State Management**, and a **Component-Based Architecture** into a lightweight package. -## Installation +## 🚀 Key Features -You can import the modules directly into your project. +- **IoC Container**: Centralized dependency management using a robust container and `app()` helper. +- **Reactive State**: Simple, proxy-based state management with automatic UI updates. +- **Event-Driven**: Powerful `Broker` and `Mediator` pattern for decoupled communication. +- **Component-Based**: Class-based components with lifecycle hooks and functional stateless components. +- **Fluent Router**: Expressive, fluent API for client-side routing. +- **Lightweight**: Zero dependencies (other than dev tools), pure JavaScript. -```javascript -import { Component, h, Runner } from './path/to/modular-openscript/index.js'; -``` - -## Quick Start - -See [`examples/full-application.js`](./examples/full-application.js) for a complete real-world example. - -```javascript -import { Component, Mediator, h, broker, context, putContext, state, router } from './modular-openscript/index.js'; - -// 1. Register events -const $e = { - user: { authenticated: true, loggedOut: true } -}; -broker.registerEvents($e); - -// 2. Initialize context -putContext("user", "UserContext"); -const uc = context("user"); -uc.states({ isLoggedIn: false }); +--- -// 3. Create a component -class Dashboard extends Component { - render(...args) { - return h.div("Welcome to OpenScript!", ...args); - } -} +## 📦 Installation -// 4. Mount to DOM -const dashboard = new Dashboard(); -dashboard.mount(document.getElementById("app")); +```bash +npm install modular-openscript ``` -## Core Concepts +--- -### Component +## ⚡ Quick Start -Components are the building blocks of your UI. They extend the `Component` class. +Create a simple counter application in seconds. ```javascript -import { Component, h } from './modular-openscript/index.js'; - -class MyComponent extends Component { - render(...args) { - return h.div('Hello World', ...args); - } -} -``` - -### Runner - -The `Runner` class is used to initialize and mount your components. - -```javascript -import { Runner } from './modular-openscript/index.js'; -import MyComponent from './MyComponent.js'; - -new Runner().run(MyComponent); -``` - -### State - -State management is handled by the `State` class. +import { Component, h, state, ojs } from "modular-openscript"; -```javascript -import { State } from './modular-openscript/index.js'; +// 1. Define State +const counter = state(0); -const myState = State.state(0); +// 2. Create Component +class CounterApp extends Component { + render() { + return h.div( + h.h1(`Count: ${counter.value}`), + h.button({ onclick: () => counter.value++ }, "Increment") + ); + } +} -myState.value++; // Triggers updates +// 3. Run Application +ojs(CounterApp); ``` -### Router +--- -Client-side routing is managed by the `Router` class. +## 🏗️ Architecture Overview -```javascript -import { router } from './modular-openscript/index.js'; +OpenScript is built around a central **IoC Container**. Instead of importing global instances, you access core services via the `app()` helper. -router.on('home', () => { - console.log('Home page'); -}); +### Core Services -router.listen(); -``` +- **`app('broker')`**: The central event bus. +- **`app('router')`**: The client-side router. +- **`app('contextProvider')`**: Manages application contexts. -### Broker +### The `app()` Helper -The `Broker` acts as a central event bus. +The `app()` function is your gateway to the container: ```javascript -import { broker } from './modular-openscript/index.js'; +import { app } from "modular-openscript"; -broker.on('my-event', (data) => { - console.log(data); -}); +// Access services +const router = app("router"); +const broker = app("broker"); -broker.emit('my-event', { some: 'data' }); +// Register values +app().value("myConfig", { debug: true }); ``` +--- -## Advanced Features +## 🧩 Components -### Fragments -Use `h.$` or `h._` to create document fragments, allowing you to return multiple elements without a parent wrapper. +Components are the building blocks of your UI. They can be **Class-based** (stateful) or **Functional** (stateless). -```javascript -import { Component, h } from './modular-openscript/index.js'; - -class FragmentComponent extends Component { - render(...args) { - return h.$( - h.h3("Header"), - h.p("Paragraph 1"), - h.p("Paragraph 2") - ); - } -} -``` +### Class Components -### State Management -OpenScript provides a simple reactive state system. +Extend `Component` to create stateful components with lifecycle hooks. ```javascript -import { Component, h, state } from './modular-openscript/index.js'; +import { Component, h } from "modular-openscript"; -const counter = state(0); - -class CounterComponent extends Component { - render(...args) { - return h.div( - h.h3(`Count: ${counter.value}`), - h.button({ onclick: () => counter.value++ }, "Increment"), - ...args - ); - } +class MyComponent extends Component { + // Lifecycle: Called when component is mounted to DOM + async mount() { + console.log("Mounted!"); + } + + render(...args) { + return h.div("Hello World", ...args); + } } ``` -### Context -Contexts allow you to share state across components. +### Functional Components -```javascript -import { Component, h, context, putContext } from './modular-openscript/index.js'; - -// Register a context -putContext("Theme", "contexts.ThemeContext"); +Simple functions that return markup. Great for presentational UI. -class ThemedComponent extends Component { - constructor() { - super(); - this.themeContext = context("Theme"); - } - // ... -} +```javascript +const Button = (text, onclick) => { + return h.button({ onclick }, text); +}; ``` -> [!WARNING] -> **Deprecation Notice**: `fetchContext` is deprecated. Please use `putContext` instead. `putContext` handles both loading and fetching logic more efficiently. +### The `h` Builder -## Context Management - -Contexts provide a way to organize and share state across your application. - -### Creating Contexts +OpenScript uses a hyperscript-like helper `h` to build DOM elements. ```javascript -import { putContext, context } from './modular-openscript/index.js'; - -// Register contexts -putContext(["global", "user", "page"], "AppContext"); - -// Access contexts -const gc = context("global"); // Global context -const uc = context("user"); // User context -const pc = context("page"); // Page context +h.div( + { class: "container", id: "main" }, // Attributes + h.h1("Title"), // Children + h.p("Content") +); ``` -### Using Context States +**Special Attributes:** -```javascript -// Initialize multiple states in a context -uc.states({ - cart: {}, - profile: null, - isLoggedIn: false -}); +- **`listeners`**: Object of event listeners (`{ click: () => ... }`). +- **`parent`**: DOM element to append to. +- **`resetParent`**: If `true`, clears parent before appending. +- **`component`**: Attach a component instance to the element. +- **`$_method`**: Prefix component methods with `$_` to use them as event handlers in markup. -// Access and modify state -uc.isLoggedIn.value = true; +--- -// Add listeners to context states -uc.cart.listener((cartState) => { - console.log(`Cart updated: ${Object.keys(cartState.value).length} items`); -}); -``` +## 🔄 State Management -**The `.states()` Helper** +State is reactive by default. When state changes, any component using that state automatically re-renders. -The `.states()` method is a convenient helper that creates multiple state properties on a context at once: +### Basic State ```javascript -// Instead of: -uc.cart = state({}); -uc.profile = state(null); -uc.isLoggedIn = state(false); +import { state } from "modular-openscript"; -// You can use: -uc.states({ - cart: {}, - profile: null, - isLoggedIn: false -}); -``` +const count = state(0); + +// Update value -> triggers UI updates +count.value++; -Each key becomes a reactive `State` property on the context, automatically created using `state()`. +// Listen to changes +count.listener((s) => console.log(s.value)); +``` -### Global State Pattern: Pass States to Components +### Contexts -**Best Practice**: Define global states in contexts, then pass them to components via the render method: +Contexts group related states and make them globally available. ```javascript -// In your initialization (e.g., declarations.js) -const pc = context("page"); -pc.states({ - pageTitle: "Home", - loading: false -}); +import { context, putContext } from "modular-openscript"; -// Pass state to component when rendering -h.HomePage(pc.pageTitle, { - parent: document.getElementById("root"), - resetParent: true -}); -``` +// 1. Register Context +putContext("user", "UserContext"); -**Component receives state in render:** +// 2. Initialize States +const uc = context("user"); +uc.states({ + name: "Guest", + isLoggedIn: false, +}); -```javascript -class HomePage extends Component { - // State is passed as parameter - render(pageTitle, ...args) { - return h.div( - h.h1(pageTitle.value), // Access via .value - h.p("Welcome to the home page"), - ...args - ); - } +// 3. Use in Component +class UserProfile extends Component { + render() { + // Component auto-updates when uc.name changes + return h.div(`User: ${uc.name.value}`); + } } ``` -This pattern: -- ✅ Centralizes state management in contexts -- ✅ Makes components reusable and testable -- ✅ Automatically re-renders when state changes -- ✅ Keeps component logic clean - -### Context Properties - -You can also add non-reactive properties to contexts: - -```javascript -gc.appName = "MyApp"; -gc.version = "1.0.0"; -``` +--- -## Event Handling +## 📡 Event System -OpenScript provides a powerful event-driven architecture. +OpenScript uses a **Broker/Mediator** pattern to decouple business logic from UI. -### Event Registration Pattern +### 1. Register Events -Before using events, register them with the broker. This creates a centralized event catalog: +Define your events in a structured object. ```javascript -import { broker } from './modular-openscript/index.js'; - const $e = { - system: { - booted: true, - needs: { - reload: true, - } - }, - user: { - authenticated: true, - loggedOut: true, - needs: { - login: true, - logout: true, - }, - has: { - loginError: true, - } - } + user: { + login: true, + logout: true, + }, }; -// Register all events at application startup -broker.registerEvents($e); - -// Make events globally accessible -window.$e = $e; +app("broker").registerEvents($e); ``` -This pattern: -- Provides clear event documentation -- Enables autocomplete in IDEs -- Creates namespaced event names (e.g., `user:authenticated`, `user:needs:login`) +### 2. Mediators (Listeners) -### Declarative Listening (Mediators) -Use the `$$` prefix in Mediators to automatically register event listeners. Nested objects create namespaced events (e.g., `$$user.login` becomes `user:login`). +Mediators handle business logic. Use the `$$` prefix to auto-register listeners. ```javascript -import { Mediator, Utils } from './modular-openscript/index.js'; +import { Mediator, payload } from "modular-openscript"; class AuthMediator extends Mediator { - $$user = { - login: (ed, event) => { - const data = Utils.parsePayload(ed); - console.log("User logged in", data.message); - } - }; -} -``` - -### Imperative Listening -You can also listen to events directly using the global `broker` instance. - -```javascript -import { broker, Utils } from './modular-openscript/index.js'; - -broker.on("user:login", (ed, event) => { - const data = Utils.parsePayload(ed); - console.log("User logged in", data.message); -}); -``` - -### Emitting Events -Use `broker.send()` or `broker.broadcast()` to emit events. - -```javascript -broker.send("user:login", payload({ username: "Alice" })); -``` - -### Advanced Patterns - -#### Multi-Event Listening -You can listen to multiple events in a single handler by separating them with an underscore `_`. - -```javascript -class UserMediator extends Mediator { - $$user = { - // Triggers on 'user:login' OR 'user:logout' - login_logout: (ed, event) => { - console.log(`Event ${event} triggered`); - } - }; + $$user = { + // Listens to 'user:login' + login: (ed, event) => { + console.log("User logged in!"); + }, + }; } ``` -#### Component Events -Components can listen to events from other components using `h.on`. +### 3. Emitting Events -```javascript -import { Component, h } from './modular-openscript/index.js'; -import { LoginButton } from './examples/event-handling.js'; - -class Dashboard extends Component { - render(...args) { - return h.div( - // Listen to the 'rendered' event of LoginButton - h.on(LoginButton, "rendered", () => { - console.log("Login Button rendered"); - }), - h.component(new LoginButton()) - ); - } -} -``` - -#### Component Methods as Listeners -Prefix component methods with `$_` to use them easily as event handlers in your markup. +Send events from components or other services. ```javascript -class MyComponent extends Component { - $_handleClick(e) { - console.log("Clicked!"); - } - - render() { - return h.button({ onclick: this.$_handleClick }, "Click Me"); - } -} +app("broker").send("user:login", payload({ id: 1 })); ``` -### Special Attributes - -OpenScript's markup engine recognizes special attributes that control element behavior: +--- -#### DOM Manipulation Attributes +## 🛣️ Routing -**`parent`** (HTMLElement) -- Specifies which parent element to append this element to -```javascript -h.div({ parent: document.getElementById("container") }, "Content") -``` +The router uses a fluent API for defining routes. -**`resetParent`** (boolean) -- When `true`, clears all children from the parent before appending ```javascript -h.div({ parent: container, resetParent: true }, "Replace all content") -``` - -**`firstOfParent`** (boolean) -- When `true`, prepends the element as the first child of its parent -```javascript -h.div({ parent: container, firstOfParent: true }, "I'll be first") -``` - -**`replaceParent`** (boolean) -- When `true`, replaces the parent element entirely with this element -```javascript -h.div({ parent: oldElement, replaceParent: true }, "New content") -``` +const router = app("router"); -#### Event Attributes - -**`listeners`** (object) -- Attach DOM event listeners; value can be a function or array of functions -```javascript -h.button({ - listeners: { - click: handleClick, - mouseover: [handler1, handler2] - } -}, "Click me") -``` - -**`event`** (string) -- Component event name to emit after rendering -```javascript -h.div({ event: "custom:rendered" }, "Content") -``` - -**`eventParams`** (any | array) -- Parameters to pass with the component event -```javascript -h.div({ - event: "data:loaded", - eventParams: [{ id: 123 }, "extra"] -}, "Content") -``` - -#### Component Attributes - -**`component`** (Component) -- Associates a Component instance with the element -```javascript -h.div({ component: myComponentInstance }, "Wrapper") -``` - -**`c_attr`** (object) -- Custom attributes to pass to the associated component -```javascript -h.div({ c_attr: { userId: 123, role: "admin" } }) -``` - -**`$` prefix** (any) -- Shorthand for component attributes; `$userId` becomes component attribute `userId` -```javascript -h.div({ $userId: 123, $role: "admin" }) -// Equivalent to: c_attr: { userId: 123, role: "admin" } -``` +// Basic Route +router.on( + "/", + () => { + context("page").view.value = "home"; + }, + "home" +); -**`withCAttr`** (boolean) -- Flag to enable component attribute processing +// Grouped Routes +router.prefix("users").group(() => { + router.on( + "/{id}", + () => { + const userId = router.params.id; + console.log("Viewing user:", userId); + }, + "users.view" + ); +}); -**`methods`** (object) -- Methods to attach to the element, accessible via `element.methods()` -```javascript -h.div({ - methods: { - getData: () => ({ id: 1 }), - setData: (data) => console.log(data) - } -}) +// Start Router +router.listen(); ``` -### Helper Functions +--- -#### h.func() - Inline Event Handlers -Create callable string references for functions with arguments: +## 📚 Examples -```javascript -class ProductCard extends Component { - render(product) { - return h.button( - { - // h.func creates: "broker.send('cart:add', payload({...}))" - onclick: h.func( - "broker.send", - $e.cart.needs.addition, - payload({ product }) - ) - }, - "Add to Cart" - ); - } -} -``` +Check the `examples/` directory for detailed usage patterns: -#### component.method() - Component Method Reference -Reference component methods in templates: +- **`basic-usage.js`**: Simple counter app. +- **`advanced-features.js`**: Fragments and manual context registration. +- **`component-example.js`**: Component communication. +- **`event-handling.js`**: Mediators and event patterns. +- **`full-application.js`**: A complete "Real World" application structure. +- **`state-example.js`**: Deep dive into state patterns. -```javascript -class Form extends Component { - submitForm() { - console.log("Submitting..."); - } - - render() { - return h.button( - { onclick: this.method("submitForm") }, - "Submit" - ); - } -} -``` +--- -## State Management +## 🛠️ Advanced -OpenScript provides reactive state management through the `state` helper. +### Manual Context Registration -### Automatic State Listening in Components -When you pass state to a component's `render()` method or use it in the render output, the component automatically listens to state changes and re-renders. +If you need to register a context instance manually (e.g., for testing): ```javascript -import { Component, h, state } from './modular-openscript/index.js'; - -class Counter extends Component { - count = state(0); - - $_increment() { - this.count.value++; - } - - // Component automatically re-renders when this.count changes - render(...args) { - return h.div( - h.p(`Count: ${this.count.value}`), - h.button({ onclick: this.$_increment }, "Increment") - ); - } +class MyContext { + theme = state("dark"); } -``` -### Direct State Listeners -You can also add direct listeners to state using the `.listener()` method. - -```javascript -class MyComponent extends Component { - count = state(0); - - constructor() { - super(); - - // Add a direct listener - this.count.listener((currentState) => { - console.log(`Count is now: ${currentState.value}`); - }); - } - - render(...args) { - return h.div( - h.button( - { onclick: () => this.count.value++ }, - "Increment" - ) - ); - } -} +app("contextProvider").map.set("Theme", new MyContext()); ``` -### State Methods -- **`.listener(callback)`**: Add a listener that fires when state changes -- **`.once(callback)`**: Add a one-time listener -- **`.off(id)`**: Remove a listener by ID -- **`.value`**: Get or set the state value - -## Application Initialization +### Customizing the Runner -Complete application setup following Carata patterns: +The `ojs()` function is a wrapper around `Runner`. You can also use `Runner` directly if needed, but `ojs()` is recommended for simplicity. ```javascript -// 1. Define and register events -const $e = { /* event definitions */ }; -broker.registerEvents($e); +import { Runner } from "modular-openscript"; -// 2. Initialize contexts -putContext(["global", "user"], "AppContext"); -const uc = context("user"); -uc.states({ cart: {}, isLoggedIn: false }); - -// 3. Set up state listeners -uc.cart.listener((cart) => { - console.log("Cart changed"); -}); - -// 4. Initialize mediators -const cartMediator = new CartMediator(); - -// 5. Set up routing -router.on("/", () => { /* ... */ }, "home"); -router.prefix("products").group(() => { - router.on("/{id}/view", () => { /* ... */ }, "product.view"); -}); - -// 6. Mount components -const dashboard = new Dashboard(); -dashboard.mount(document.getElementById("app")); - -// 7. Broadcast system ready -broker.broadcast($e.system.booted); - -// 8. Start listening to routes -router.listen(); +const runner = new Runner(); +runner.run(MyComponent); ``` -## Routing - -OpenScript includes a built-in router for single-page applications using a fluent API: - -```javascript -import { router } from './modular-openscript/index.js'; - -// Simple route -router.on("/", () => { - console.log("Home page"); -}, "home"); - -// Route with parameters -router.on("/users/{id}", () => { - console.log(`User ID: ${router.params.id}`); -}, "user.view"); - -// Grouped routes with prefix -router.prefix("products").group(() => { - router.on("/{productId}/view", () => { - console.log(`Product: ${router.params.productId}`); - }, "product.view"); - - router.on("/create", () => { - console.log("Create product"); - }, "product.create"); -}); - -// Multiple routes to same handler -router.orOn( - ["/login", "/signin"], - () => { - console.log("Login page"); - }, - ["auth.login", "auth.signin"] -); - -// Programmatic navigation (by route name) -router.to("home"); -router.to("user.view", { id: 123 }); - -// Navigation with query strings -router.to("products.view", { productId: 456, tab: "reviews" }); -// Creates: /products/456/view?tab=reviews - -// Start listening to route changes -router.listen(); -``` - -### Router Methods -- **`.on(path, handler, name)`**: Register a route -- **`.orOn(paths, handler, names)`**: Register multiple paths to same handler -- **`.prefix(name)`**: Create a prefix for grouped routes -- **`.group(callback)`**: Group routes under a prefix -- **`.to(nameOrPath, params)`**: Navigate to a route -- **`.listen()`**: Start listening to URL changes -- **`.params`**: Access route parameters -- **`.qs`**: Access query string parameters - -## Directory Structure - - -- `core/`: Core classes like `Runner`, `Emitter`, `State`, `Context`. -- `component/`: UI related classes like `Component`, `MarkupEngine`, `h`. -- `router/`: Routing logic. -- `broker/`: Event bus logic. -- `mediator/`: Business logic mediators. -- `utils/`: Helper functions. -- `examples/`: Usage examples. +--- -## Optimizations +## 📄 License -See `optimizations.md` for suggested improvements. +MIT From 199a453796000ebe0ba44dba171df04878cdc3c8 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 01:00:49 +0300 Subject: [PATCH 05/46] working on versioning --- bin/{create-ojs-app.js => create-ojs-app} | 0 package.json | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename bin/{create-ojs-app.js => create-ojs-app} (100%) diff --git a/bin/create-ojs-app.js b/bin/create-ojs-app similarity index 100% rename from bin/create-ojs-app.js rename to bin/create-ojs-app diff --git a/package.json b/package.json index 70e0546..80d2d61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openscriptjs", - "version": "1.0.0", + "version": "1.0.1", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/openscript.umd.js", @@ -14,7 +14,7 @@ "./plugin": "./build/vite-plugin-openscript.js" }, "bin": { - "create-ojs-app": "./bin/create-ojs-app.js" + "create-ojs-app": "bin/create-ojs-app" }, "files": [ "dist", @@ -51,7 +51,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/OpenScriptJs/modular-openscript.git" + "url": "git+https://github.com/OpenScriptJs/modular-openscript.git" }, "bugs": { "url": "https://github.com/OpenScriptJs/modular-openscript/issues" From 653ece150535eed3cc8ac876183571244524089f Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 01:03:28 +0300 Subject: [PATCH 06/46] deployed the package --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 80d2d61..5d18e28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "openscriptjs", - "version": "1.0.1", + "name": "modular-openscriptjs", + "version": "1.0.0", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/openscript.umd.js", From 6f54b228f8822454050cbde278460d0a7a756bef Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 01:37:52 +0300 Subject: [PATCH 07/46] working on openscript docs --- README.md | 1129 +++++++++++++++-- README.npm.md | 124 +- bin/create-ojs-app | 2 +- examples/advanced-features.js | 4 +- examples/basic-app/events.js | 86 +- examples/basic-app/index.js | 13 +- examples/basic-app/pages/TodoApp.js | 326 ++--- examples/basic-app/routes.js | 61 +- examples/basic-usage.js | 2 +- examples/component-example.js | 4 +- examples/context-state-example.js | 244 ++-- examples/event-handling.js | 4 +- examples/state-example.js | 612 +++++---- package.json | 8 +- src/index.js | 13 +- templates/basic/src/components/App.js | 35 +- templates/basic/src/components/Counter.js | 93 +- templates/basic/src/contexts.js | 2 +- templates/basic/src/main.js | 14 +- templates/basic/src/ojs.config.js | 8 +- templates/basic/src/routes.js | 11 +- templates/bootstrap/src/components/App.js | 100 +- templates/bootstrap/src/components/Counter.js | 204 +-- templates/bootstrap/src/contexts.js | 2 +- templates/bootstrap/src/main.js | 2 +- templates/bootstrap/src/ojs.config.js | 2 +- templates/bootstrap/src/routes.js | 9 +- templates/tailwind/src/components/App.js | 36 +- templates/tailwind/src/components/Counter.js | 105 +- templates/tailwind/src/contexts.js | 2 +- templates/tailwind/src/main.js | 2 +- templates/tailwind/src/ojs.config.js | 2 +- templates/tailwind/src/routes.js | 9 +- vite.config.js | 2 +- 34 files changed, 2166 insertions(+), 1106 deletions(-) diff --git a/README.md b/README.md index 0edd39d..3419964 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,46 @@ # Modular OpenScript Framework -A modern, modular, event-driven JavaScript framework built for scalability and maintainability. OpenScript combines the power of **Inversion of Control (IoC)**, **Reactive State Management**, and a **Component-Based Architecture** into a lightweight package. +A modern, modular, event-driven JavaScript framework built for scalability and maintainability. OpenScript combines the power of **Inversion of Control (IoC)**, **Reactive State Management**, and a **Component-Based Architecture** into a lightweight package with zero runtime dependencies. ## 🚀 Key Features -- **IoC Container**: Centralized dependency management using a robust container and `app()` helper. -- **Reactive State**: Simple, proxy-based state management with automatic UI updates. -- **Event-Driven**: Powerful `Broker` and `Mediator` pattern for decoupled communication. -- **Component-Based**: Class-based components with lifecycle hooks and functional stateless components. -- **Fluent Router**: Expressive, fluent API for client-side routing. -- **Lightweight**: Zero dependencies (other than dev tools), pure JavaScript. +- **IoC Container**: Centralized dependency management using a robust container and `app()` helper +- **Reactive State**: Proxy-based state management with automatic UI updates +- **Event-Driven**: Powerful `Broker` and `Mediator` pattern for decoupled communication +- **Component-Based**: Class-based components with lifecycle hooks and functional stateless components +- **Fluent Router**: Expressive, fluent API for client-side routing with nested routes +- **Lightweight**: Zero runtime dependencies, pure JavaScript +- **TypeScript Ready**: Built with modern ES6+ features +- **Vite Plugin**: Build tools for minification-safe production builds --- ## 📦 Installation +### Using npm/yarn + +```bash +npm install modular-openscriptjs +# or +yarn add modular-openscriptjs +``` + +### Create a New Project + +The fastest way to get started is using the project scaffolding tool: + ```bash -npm install modular-openscript +npx create-ojs-app my-app +cd my-app +npm run dev ``` +**Available Templates:** + +- `basic` - Clean starter with vanilla CSS and simple structure +- `tailwind` - Pre-configured with TailwindCSS and responsive design +- `bootstrap` - Bootstrap 5 integration with utility classes + --- ## ⚡ Quick Start @@ -26,7 +48,9 @@ npm install modular-openscript Create a simple counter application in seconds. ```javascript -import { Component, h, state, ojs } from "modular-openscript"; +import { Component, app, state, ojs } from "modular-openscriptjs"; + +const h = app("h"); // 1. Define State const counter = state(0); @@ -53,25 +77,45 @@ OpenScript is built around a central **IoC Container**. Instead of importing glo ### Core Services -- **`app('broker')`**: The central event bus. -- **`app('router')`**: The client-side router. -- **`app('contextProvider')`**: Manages application contexts. +The framework provides these built-in services through the container: + +| Service | Access | Description | +| ------------------- | ------------------------ | --------------------------------------------- | +| **h** | `app('h')` | Hyperscript builder for creating DOM elements | +| **broker** | `app('broker')` | Event bus for application-wide communication | +| **router** | `app('router')` | Client-side routing manager | +| **contextProvider** | `app('contextProvider')` | Manages application contexts | +| **mediatorManager** | `app('mediatorManager')` | Handles mediator registration | +| **loader** | `app('loader')` | Auto-loader for dynamic imports | ### The `app()` Helper The `app()` function is your gateway to the container: ```javascript -import { app } from "modular-openscript"; +import { app } from "modular-openscriptjs"; // Access services +const h = app("h"); const router = app("router"); const broker = app("broker"); -// Register values -app().value("myConfig", { debug: true }); +// Register custom values +app().value("apiUrl", "https://api.example.com"); +app().value("config", { debug: true, theme: "dark" }); + +// Access registered values +const apiUrl = app("apiUrl"); +const config = app("config"); ``` +**Benefits:** + +- Single source of truth for dependencies +- Easy to mock services for testing +- Runtime service replacement +- Better code organization + --- ## 🧩 Components @@ -83,49 +127,136 @@ Components are the building blocks of your UI. They can be **Class-based** (stat Extend `Component` to create stateful components with lifecycle hooks. ```javascript -import { Component, h } from "modular-openscript"; +import { Component, app, state } from "modular-openscriptjs"; + +const h = app("h"); + +class UserProfile extends Component { + constructor() { + super(); + this.username = state("Guest"); + this.avatar = state("/default-avatar.png"); + } -class MyComponent extends Component { // Lifecycle: Called when component is mounted to DOM async mount() { - console.log("Mounted!"); + console.log("Component mounted!"); + // Fetch user data, set up listeners, etc. + await this.loadUserData(); + } + + // Lifecycle: Called when component is removed from DOM + unmount() { + console.log("Component unmounted!"); + // Clean up listeners, timers, etc. + } + + async loadUserData() { + const apiUrl = app("apiUrl"); + const response = await fetch(`${apiUrl}/user`); + const data = await response.json(); + this.username.value = data.name; + this.avatar.value = data.avatar; } render(...args) { - return h.div("Hello World", ...args); + return h.div( + { class: "user-profile" }, + h.img({ src: this.avatar.value, alt: "Avatar" }), + h.h2(this.username.value), + ...args + ); } } ``` +**Component Lifecycle:** + +1. `constructor()` - Initialize state and properties +2. `mount()` - Component added to DOM (async supported) +3. `render()` - Generate component markup +4. `unmount()` - Component removed from DOM + ### Functional Components Simple functions that return markup. Great for presentational UI. ```javascript -const Button = (text, onclick) => { - return h.button({ onclick }, text); +const Button = (text, onclick, variant = "primary") => { + return h.button( + { + class: `btn btn-${variant}`, + onclick, + }, + text + ); }; + +const Card = (title, content) => { + return h.div( + { class: "card" }, + h.div({ class: "card-header" }, h.h3(title)), + h.div({ class: "card-body" }, content) + ); +}; + +// Usage +h.div( + Button("Click Me", () => console.log("Clicked!"), "success"), + Card("Welcome", h.p("This is a card component")) +); ``` -### The `h` Builder +### The `h` Builder (Hyperscript) -OpenScript uses a hyperscript-like helper `h` to build DOM elements. +OpenScript uses a hyperscript-like helper `h` to build DOM elements efficiently. ```javascript +const h = app("h"); + +// Basic element +h.div("Hello World"); + +// With attributes +h.div({ class: "container", id: "main" }, "Content"); + +// Nested elements h.div( - { class: "container", id: "main" }, // Attributes - h.h1("Title"), // Children - h.p("Content") + { class: "card" }, + h.header(h.h1("Title")), + h.section(h.p("Paragraph 1"), h.p("Paragraph 2")), + h.footer(h.small("Footer text")) ); + +// Arrays of elements +h.ul(...["Apple", "Banana", "Cherry"].map((fruit) => h.li(fruit))); ``` **Special Attributes:** -- **`listeners`**: Object of event listeners (`{ click: () => ... }`). -- **`parent`**: DOM element to append to. -- **`resetParent`**: If `true`, clears parent before appending. -- **`component`**: Attach a component instance to the element. -- **`$_method`**: Prefix component methods with `$_` to use them as event handlers in markup. +| Attribute | Description | Example | +| --------------------------- | ----------------------------- | ------------------------------ | +| `listeners` | Object of event listeners | `{ listeners: { click: fn } }` | +| `parent` | DOM element to append to | `{ parent: document.body }` | +| `resetParent` | Clear parent before appending | `{ resetParent: true }` | +| `component` | Attach component instance | `{ component: myComponent }` | +| `onclick`, `onchange`, etc. | Direct event handlers | `{ onclick: () => {} }` | + +**Component Methods as Event Handlers:** + +Prefix component methods with `$_` to use them directly in markup: + +```javascript +class MyComponent extends Component { + $_handleClick(event) { + console.log("Button clicked", event); + } + + render() { + return h.button({ onclick: this.$_handleClick }, "Click Me"); + } +} +``` --- @@ -136,147 +267,847 @@ State is reactive by default. When state changes, any component using that state ### Basic State ```javascript -import { state } from "modular-openscript"; +import { state } from "modular-openscriptjs"; +// Create reactive state const count = state(0); +// Read value +console.log(count.value); // 0 + // Update value -> triggers UI updates count.value++; // Listen to changes -count.listener((s) => console.log(s.value)); +count.listener((stateObj) => { + console.log("New value:", stateObj.value); + console.log("Previous value:", stateObj.previousValue); +}); + +// Conditional updates +if (count.value > 10) { + count.value = 0; +} +``` + +### State in Components + +```javascript +class TodoList extends Component { + constructor() { + super(); + this.todos = state([]); + this.filter = state("all"); // "all", "active", "completed" + } + + addTodo(text) { + this.todos.value = [ + ...this.todos.value, + { id: Date.now(), text, completed: false }, + ]; + } + + toggleTodo(id) { + this.todos.value = this.todos.value.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo + ); + } + + removeTodo(id) { + this.todos.value = this.todos.value.filter((todo) => todo.id !== id); + } + + get filteredTodos() { + switch (this.filter.value) { + case "active": + return this.todos.value.filter((t) => !t.completed); + case "completed": + return this.todos.value.filter((t) => t.completed); + default: + return this.todos.value; + } + } + + render() { + return h.div( + h.input({ + type: "text", + placeholder: "Add todo...", + onkeypress: (e) => { + if (e.key === "Enter" && e.target.value) { + this.addTodo(e.target.value); + e.target.value = ""; + } + }, + }), + h.div( + ...["all", "active", "completed"].map((filter) => + h.button( + { + class: this.filter.value === filter ? "active" : "", + onclick: () => (this.filter.value = filter), + }, + filter.toUpperCase() + ) + ) + ), + h.ul( + ...this.filteredTodos.map((todo) => + h.li( + { class: todo.completed ? "completed" : "" }, + h.input({ + type: "checkbox", + checked: todo.completed, + onchange: () => this.toggleTodo(todo.id), + }), + h.span(todo.text), + h.button({ onclick: () => this.removeTodo(todo.id) }, "×") + ) + ) + ) + ); + } +} ``` ### Contexts -Contexts group related states and make them globally available. +Contexts group related states and make them globally available across your application. ```javascript -import { context, putContext } from "modular-openscript"; +import { context, putContext, Component, app } from "modular-openscriptjs"; + +const h = app("h"); // 1. Register Context putContext("user", "UserContext"); +putContext("app", "AppContext"); // 2. Initialize States const uc = context("user"); uc.states({ name: "Guest", + email: "", isLoggedIn: false, + role: "guest", }); -// 3. Use in Component +const ac = context("app"); +ac.states({ + theme: "light", + language: "en", + notifications: [], +}); + +// 3. Use in Components class UserProfile extends Component { render() { - // Component auto-updates when uc.name changes - return h.div(`User: ${uc.name.value}`); + // Component auto-updates when uc.name or uc.email changes + return h.div( + h.h2(`Welcome, ${uc.name.value}!`), + h.p(`Email: ${uc.email.value}`), + h.p(`Role: ${uc.role.value}`), + uc.isLoggedIn.value + ? h.button({ onclick: this.logout }, "Logout") + : h.button({ onclick: this.login }, "Login") + ); + } + + login() { + uc.name.value = "John Doe"; + uc.email.value = "john@example.com"; + uc.isLoggedIn.value = true; + uc.role.value = "admin"; + } + + logout() { + uc.name.value = "Guest"; + uc.email.value = ""; + uc.isLoggedIn.value = false; + uc.role.value = "guest"; + } +} + +// 4. Access from anywhere +class ThemeToggle extends Component { + toggleTheme() { + ac.theme.value = ac.theme.value === "light" ? "dark" : "light"; + } + + render() { + return h.button( + { onclick: this.toggleTheme.bind(this) }, + `Theme: ${ac.theme.value}` + ); } } ``` +**Best Practices:** + +- Use contexts for truly global state +- Keep related state together +- Use meaningful context names +- Initialize all states upfront + --- ## 📡 Event System -OpenScript uses a **Broker/Mediator** pattern to decouple business logic from UI. +OpenScript uses a **Broker/Mediator** pattern to decouple business logic from UI components. ### 1. Register Events -Define your events in a structured object. +Define your events in a structured object: ```javascript +import { app } from "modular-openscriptjs"; + +const broker = app("broker"); + const $e = { user: { login: true, logout: true, + registered: true, + profileUpdated: true, + }, + todo: { + added: true, + removed: true, + completed: true, + uncompleted: true, + }, + app: { + initialized: true, + themeChanged: true, + errorOccurred: true, }, }; -app("broker").registerEvents($e); +// Register all events +broker.registerEvents($e); ``` -### 2. Mediators (Listeners) +Event names become namespaced: `user:login`, `todo:added`, etc. + +### 2. Mediators (Business Logic Handlers) -Mediators handle business logic. Use the `$$` prefix to auto-register listeners. +Mediators handle business logic and respond to events: ```javascript -import { Mediator, payload } from "modular-openscript"; +import { Mediator, payload, app } from "modular-openscriptjs"; + +const broker = app("broker"); class AuthMediator extends Mediator { + // The '$$' prefix auto-registers these as event listeners $$user = { // Listens to 'user:login' - login: (ed, event) => { - console.log("User logged in!"); + login: (ed, eventName) => { + const data = Utils.parsePayload(ed); + console.log("User logged in:", data.message); + + // Perform business logic + this.saveToLocalStorage(data.message); + this.updateAnalytics("login", data.message); + + // Emit subsequent events + broker.send( + "user:profileUpdated", + payload({ + userId: data.message.id, + }) + ); + }, + + // Listens to 'user:logout' + logout: (ed, eventName) => { + console.log("User logged out"); + this.clearLocalStorage(); + this.updateAnalytics("logout"); + }, + + // Listens to 'user:registered' + registered: (ed, eventName) => { + const data = Utils.parsePayload(ed); + this.sendWelcomeEmail(data.message.email); + this.createUserProfile(data.message); + }, + }; + + $$app = { + errorOccurred: (ed, eventName) => { + const error = Utils.parsePayload(ed).message; + this.logErrorToService(error); + this.showNotification("An error occurred", "error"); + }, + }; + + saveToLocalStorage(user) { + localStorage.setItem("user", JSON.stringify(user)); + } + + clearLocalStorage() { + localStorage.removeItem("user"); + } + + updateAnalytics(action, data = {}) { + // Send to analytics service + console.log("Analytics:", action, data); + } + + sendWelcomeEmail(email) { + // API call to send email + console.log("Sending welcome email to:", email); + } + + createUserProfile(user) { + // API call to create profile + console.log("Creating profile for:", user); + } + + logErrorToService(error) { + // Send to error tracking service + console.error("Error logged:", error); + } + + showNotification(message, type) { + // Show UI notification + broker.send("ui:showNotification", payload({ message, type })); + } +} + +// Instantiate mediator (auto-registers all listeners) +new AuthMediator(); +``` + +### 3. Multi-Event Listeners + +Listen to multiple events with a single handler using underscore: + +```javascript +class NotificationMediator extends Mediator { + $$user = { + // Triggers on BOTH 'user:login' AND 'user:logout' + login_logout: (ed, eventName) => { + console.log(`User authentication event: ${eventName}`); + this.showNotification(`User ${eventName.split(":")[1]} event occurred`); }, }; } ``` -### 3. Emitting Events +### 4. Emitting Events -Send events from components or other services. +Send events from anywhere in your application: ```javascript -app("broker").send("user:login", payload({ id: 1 })); +import { app, payload } from "modular-openscriptjs"; + +const broker = app("broker"); + +// Simple event +broker.send( + "user:login", + payload({ + id: 123, + username: "john_doe", + }) +); + +// With metadata +broker.send( + "todo:added", + payload( + { todo: { id: 1, text: "Buy milk" } }, // message + { timestamp: Date.now(), source: "ui" } // meta + ) +); + +// Error event +broker.send( + "app:errorOccurred", + payload({ + error: new Error("Network failure"), + context: "API request", + }) +); +``` + +### 5. Component Event Listeners + +Components can also listen to events: + +```javascript +class Dashboard extends Component { + async mount() { + // Subscribe to events + app("broker").on("user:login", (ed) => { + const user = Utils.parsePayload(ed).message; + console.log("Dashboard received login:", user); + this.refreshData(); + }); + + app("broker").on("todo:added", (ed) => { + this.updateTodoCount(); + }); + } + + refreshData() { + // Reload dashboard data + } + + updateTodoCount() { + // Update todo counter + } + + render() { + return h.div({ class: "dashboard" }, "Dashboard Content"); + } +} ``` --- ## 🛣️ Routing -The router uses a fluent API for defining routes. +The router uses a fluent API for defining routes with support for parameters, groups, and middleware. + +### Basic Routing ```javascript +import { app, context } from "modular-openscriptjs"; + const router = app("router"); +const h = app("h"); -// Basic Route +// Define routes router.on( "/", () => { - context("page").view.value = "home"; + h.HomePage({ parent: document.body, resetParent: true }); + }, + "home" // Route name +); + +router.on( + "/about", + () => { + h.AboutPage({ parent: document.body, resetParent: true }); + }, + "about" +); + +router.on( + "/contact", + () => { + h.ContactPage({ parent: document.body, resetParent: true }); + }, + "contact" +); + +// Start listening to route changes +router.listen(); +``` + +### Route Parameters + +```javascript +// Single parameter +router.on( + "/users/{id}", + () => { + const userId = router.params.id; + console.log("Viewing user:", userId); + h.UserProfile(userId, { parent: document.body, resetParent: true }); }, - "home" + "users.view" ); -// Grouped Routes -router.prefix("users").group(() => { +// Multiple parameters +router.on( + "/posts/{postId}/comments/{commentId}", + () => { + const { postId, commentId } = router.params; + console.log("Post:", postId, "Comment:", commentId); + h.CommentView(postId, commentId, { + parent: document.body, + resetParent: true, + }); + }, + "posts.comments.view" +); +``` + +### Grouped Routes + +```javascript +// Group with prefix +router.prefix("admin").group(() => { + router.on( + "/", + () => { + h.AdminDashboard({ parent: document.body, resetParent: true }); + }, + "admin.dashboard" + ); + + router.on( + "/users", + () => { + h.AdminUsers({ parent: document.body, resetParent: true }); + }, + "admin.users" + ); + router.on( - "/{id}", + "/settings", () => { - const userId = router.params.id; - console.log("Viewing user:", userId); + h.AdminSettings({ parent: document.body, resetParent: true }); }, - "users.view" + "admin.settings" ); }); +// Routes: /admin, /admin/users, /admin/settings + +// Nested groups +router.prefix("api").group(() => { + router.prefix("v1").group(() => { + router.on( + "/users", + () => { + /* ... */ + }, + "api.v1.users" + ); + router.on( + "/posts", + () => { + /* ... */ + }, + "api.v1.posts" + ); + }); +}); +// Routes: /api/v1/users, /api/v1/posts +``` -// Start Router -router.listen(); +### Default Route + +```javascript +// Redirect to home if no route matches +router.default(() => router.to("home")); + +// Or show 404 page +router.default(() => { + h.NotFoundPage({ parent: document.body, resetParent: true }); +}); +``` + +### Programmatic Navigation + +```javascript +// Navigate to a named route +router.to("users.view"); + +// Navigate with parameters +router.push("/users/123"); + +// Navigate with state +router.to("profile", { userId: 456 }); + +// Go back +router.back(); +``` + +### Router Base Path + +```javascript +// Set base path for deployment in subdirectories +router.basePath("/my-app"); + +// Now routes are relative to /my-app +router.on("/home", () => { ... }); // Actual path: /my-app/home ``` --- ## 📚 Examples -Check the `examples/` directory for detailed usage patterns: +### Complete Todo App + +```javascript +import { + Component, + app, + state, + context, + putContext, + ojs, +} from "modular-openscriptjs"; + +const h = app("h"); + +// Setup context +putContext("todos", "TodoContext"); +const tc = context("todos"); +tc.states({ + todos: [], + filter: "all", +}); + +class TodoApp extends Component { + constructor() { + super(); + this.newTodoText = state(""); + } + + addTodo() { + if (this.newTodoText.value.trim()) { + tc.todos.value = [ + ...tc.todos.value, + { + id: Date.now(), + text: this.newTodoText.value, + completed: false, + }, + ]; + this.newTodoText.value = ""; + } + } + + toggleTodo(id) { + tc.todos.value = tc.todos.value.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo + ); + } + + deleteTodo(id) { + tc.todos.value = tc.todos.value.filter((todo) => todo.id !== id); + } + + get filteredTodos() { + switch (tc.filter.value) { + case "active": + return tc.todos.value.filter((t) => !t.completed); + case "completed": + return tc.todos.value.filter((t) => t.completed); + default: + return tc.todos.value; + } + } + + get stats() { + const total = tc.todos.value.length; + const completed = tc.todos.value.filter((t) => t.completed).length; + const active = total - completed; + return { total, completed, active }; + } + + render() { + const stats = this.stats; + + return h.div( + { class: "todo-app" }, + h.header( + h.h1("My Todos"), + h.p(`${stats.active} active, ${stats.completed} completed`) + ), + h.div( + { class: "todo-input" }, + h.input({ + type: "text", + placeholder: "What needs to be done?", + value: this.newTodoText.value, + oninput: (e) => (this.newTodoText.value = e.target.value), + onkeypress: (e) => { + if (e.key === "Enter") this.addTodo(); + }, + }), + h.button({ onclick: () => this.addTodo() }, "Add") + ), + h.div( + { class: "filters" }, + ...["all", "active", "completed"].map((filter) => + h.button( + { + class: tc.filter.value === filter ? "active" : "", + onclick: () => (tc.filter.value = filter), + }, + filter.charAt(0).toUpperCase() + filter.slice(1) + ) + ) + ), + h.ul( + { class: "todo-list" }, + ...this.filteredTodos.map((todo) => + h.li( + { class: todo.completed ? "completed" : "" }, + h.input({ + type: "checkbox", + checked: todo.completed, + onchange: () => this.toggleTodo(todo.id), + }), + h.span({ class: "todo-text" }, todo.text), + h.button( + { + class: "delete-btn", + onclick: () => this.deleteTodo(todo.id), + }, + "×" + ) + ) + ) + ) + ); + } +} + +ojs(TodoApp); +``` + +### Form Validation Example + +```javascript +class SignupForm extends Component { + constructor() { + super(); + this.formData = state({ + email: "", + password: "", + confirmPassword: "", + }); + this.errors = state({}); + this.isSubmitting = state(false); + } + + updateField(field, value) { + this.formData.value = { + ...this.formData.value, + [field]: value, + }; + // Clear error when user types + this.clearError(field); + } + + clearError(field) { + const newErrors = { ...this.errors.value }; + delete newErrors[field]; + this.errors.value = newErrors; + } + + validate() { + const errors = {}; + const { email, password, confirmPassword } = this.formData.value; + + if (!email) { + errors.email = "Email is required"; + } else if (!/^\S+@\S+\.\S+$/.test(email)) { + errors.email = "Invalid email format"; + } + + if (!password) { + errors.password = "Password is required"; + } else if (password.length < 8) { + errors.password = "Password must be at least 8 characters"; + } + + if (password !== confirmPassword) { + errors.confirmPassword = "Passwords do not match"; + } + + this.errors.value = errors; + return Object.keys(errors).length === 0; + } + + async handleSubmit(e) { + e.preventDefault(); + + if (!this.validate()) return; + + this.isSubmitting.value = true; + + try { + const response = await fetch("/api/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(this.formData.value), + }); + + if (response.ok) { + alert("Signup successful!"); + // Reset form + this.formData.value = { email: "", password: "", confirmPassword: "" }; + } else { + this.errors.value = { form: "Signup failed. Please try again." }; + } + } catch (error) { + this.errors.value = { form: error.message }; + } finally { + this.isSubmitting.value = false; + } + } -- **`basic-usage.js`**: Simple counter app. -- **`advanced-features.js`**: Fragments and manual context registration. -- **`component-example.js`**: Component communication. -- **`event-handling.js`**: Mediators and event patterns. -- **`full-application.js`**: A complete "Real World" application structure. -- **`state-example.js`**: Deep dive into state patterns. + renderField(label, field, type = "text") { + const error = this.errors.value[field]; + return h.div( + { class: "form-group" }, + h.label(label), + h.input({ + type, + value: this.formData.value[field], + oninput: (e) => this.updateField(field, e.target.value), + class: error ? "error" : "", + }), + error && h.span({ class: "error-message" }, error) + ); + } + + render() { + return h.form( + { onsubmit: this.handleSubmit.bind(this) }, + h.h2("Sign Up"), + this.errors.value.form && + h.div({ class: "error-banner" }, this.errors.value.form), + this.renderField("Email", "email", "email"), + this.renderField("Password", "password", "password"), + this.renderField("Confirm Password", "confirmPassword", "password"), + h.button( + { + type: "submit", + disabled: this.isSubmitting.value, + }, + this.isSubmitting.value ? "Submitting..." : "Sign Up" + ) + ); + } +} +``` + +Check the `examples/` directory for more detailed usage patterns: + +- **`basic-usage.js`**: Simple counter app +- **`advanced-features.js`**: Fragments and manual context registration +- **`component-example.js`**: Component communication +- **`event-handling.js`**: Mediators and event patterns +- **`state-example.js`**: Deep dive into state patterns +- **`context-state-example.js`**: Global state management --- -## 🛠️ Advanced +## 🛠️ Advanced Topics ### Manual Context Registration If you need to register a context instance manually (e.g., for testing): ```javascript +import { app } from "modular-openscriptjs"; + class MyContext { theme = state("dark"); + language = state("en"); } app("contextProvider").map.set("Theme", new MyContext()); @@ -284,17 +1115,171 @@ app("contextProvider").map.set("Theme", new MyContext()); ### Customizing the Runner -The `ojs()` function is a wrapper around `Runner`. You can also use `Runner` directly if needed, but `ojs()` is recommended for simplicity. +The `ojs()` function is a wrapper around `Runner`. You can use `Runner` directly for more control: ```javascript -import { Runner } from "modular-openscript"; +import { Runner } from "modular-openscriptjs"; const runner = new Runner(); runner.run(MyComponent); ``` +### Fragment Support + +Use fragments to return multiple elements without a wrapper: + +```javascript +class MyComponent extends Component { + render() { + return h.$( + // or h._ + h.h1("Title"), + h.p("Paragraph 1"), + h.p("Paragraph 2") + ); + } +} +``` + +### Lazy Loading Components + +```javascript +import { app } from "modular-openscriptjs"; + +const loader = app("loader"); + +// Lazy load a component +const LazyComponent = await loader.req("components.LazyComponent"); +``` + +--- + +## 🔧 Configuration + +### Vite Plugin + +For production builds with proper minification: + +```javascript +// vite.config.js +import { defineConfig } from "vite"; +import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; + +export default defineConfig({ + plugins: [openScriptComponentPlugin()], + build: { + target: "es2015", + minify: "terser", + }, +}); +``` + +### TypeScript Support + +While OpenScript is written in vanilla JavaScript, it works well with TypeScript: + +```typescript +import { Component, app, state, State } from "modular-openscriptjs"; + +interface Todo { + id: number; + text: string; + completed: boolean; +} + +class TodoList extends Component { + todos: State; + + constructor() { + super(); + this.todos = state([]); + } + + // ... rest of implementation +} +``` + +--- + +## 📈 Best Practices + +### State Management + +- ✅ Use contexts for truly global state +- ✅ Keep component state local when possible +- ✅ Use computed properties (getters) for derived state +- ❌ Don't mutate state directly, always reassign + +### Component Design + +- ✅ Keep components small and focused +- ✅ Use functional components for presentational UI +- ✅ Leverage lifecycle hooks appropriately +- ❌ Don't mix business logic with UI logic + +### Event System + +- ✅ Use mediators for business logic +- ✅ Keep event names well-organized +- ✅ Document your event structure +- ❌ Don't emit events in tight loops + +### Performance + +- ✅ Use `resetParent` to clear before rendering +- ✅ Minimize state updates +- ✅ Use fragments to avoid unnecessary wrapper elements +- ❌ Don't create new functions in render (use component methods) + +--- + +## 🐛 Troubleshooting + +### Component Not Re-rendering + +- Ensure state is updated via `.value` assignment +- Check that state is actually being used in `render()` +- Verify component is properly mounted + +### Events Not Firing + +- Confirm events are registered with broker +- Check event names match exactly (remember the namespace) +- Ensure mediators are instantiated + +### Router Not Working + +- Call `router.listen()` after defining routes +- Check browser console for errors +- Verify route paths are correct + --- ## 📄 License -MIT +MIT © Levi Kamara Zwannah + +--- + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +--- + +## 🔗 Links + +- [GitHub Repository](https://github.com/yourusername/modular-openscriptjs) +- [Issue Tracker](https://github.com/yourusername/modular-openscriptjs/issues) +- [npm Package](https://www.npmjs.com/package/modular-openscriptjs) +- [Documentation](https://github.com/yourusername/modular-openscriptjs/wiki) + +--- + +**Built with ❤️ using OpenScript** diff --git a/README.npm.md b/README.npm.md index f02e0c5..407e947 100644 --- a/README.npm.md +++ b/README.npm.md @@ -1,6 +1,6 @@ # OpenScriptJs -[![npm version](https://badge.fury.io/js/openscriptjs.svg)](https://www.npmjs.com/package/openscriptjs) +[![npm version](https://badge.fury.io/js/modular-openscriptjs.svg)](https://www.npmjs.com/package/modular-openscriptjs) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) A lightweight, reactive JavaScript framework for building modern web applications with components, state management, routing, and event-driven architecture. @@ -20,52 +20,45 @@ A lightweight, reactive JavaScript framework for building modern web application ### Installation -```bash -npm install openscriptjs -``` - -### Create a New Project - -```bash -npm create openscript my-app -cd my-app -npm run dev -``` - -Choose from templates: +````bash - `basic` - Clean starter with vanilla CSS - `tailwind` - Pre-configured with TailwindCSS ## 📖 Basic Usage ```javascript -import { Component, h, state } from 'openscriptjs'; +import { Component, app, state } from "modular-openscriptjs"; + +const h = app("h"); class Counter extends Component { - constructor() { - super(); - this.count = state(0); - } - - increment() { - this.count.value++; - } - - render() { - return h.div( - h.h2("Count: ", this.count.value), - h.button({ - listeners: { click: this.increment.bind(this) } - }, "Increment") - ); - } + constructor() { + super(); + this.count = state(0); + } + + increment() { + this.count.value++; + } + + render() { + return h.div( + h.h2("Count: ", this.count.value), + h.button( + { + listeners: { click: this.increment.bind(this) }, + }, + "Increment" + ) + ); + } } // Mount and render const counter = new Counter(); await counter.mount(); h.Counter({ parent: document.body }); -``` +```` ## 🏗️ Project Structure @@ -85,23 +78,21 @@ my-app/ ### Components ```javascript -import { Component, h } from 'openscriptjs'; +import { Component, app } from "modular-openscriptjs"; + +const h = app("h"); class MyComponent extends Component { - render(...args) { - return h.div( - { class: "container" }, - h.h1("Hello OpenScript!"), - ...args - ); - } + render(...args) { + return h.div({ class: "container" }, h.h1("Hello OpenScript!"), ...args); + } } ``` ### State Management ```javascript -import { state } from 'openscriptjs'; +import { state } from "modular-openscriptjs"; // Create reactive state const count = state(0); @@ -110,20 +101,23 @@ const count = state(0); count.value = 10; // Listen to changes -count.listener((s) => console.log('New value:', s.value)); +count.listener((s) => console.log("New value:", s.value)); ``` ### Routing ```javascript -import { router, h } from 'openscriptjs'; +import { app } from "modular-openscriptjs"; + +const router = app("router"); +const h = app("h"); -router.on('/home', () => { - h.HomePage({ parent: document.body, resetParent: true }); +router.on("/home", () => { + h.HomePage({ parent: document.body, resetParent: true }); }); -router.on('/about', () => { - h.AboutPage({ parent: document.body, resetParent: true }); +router.on("/about", () => { + h.AboutPage({ parent: document.body, resetParent: true }); }); router.listen(); @@ -132,7 +126,9 @@ router.listen(); ### Context & Global State ```javascript -import { context, putContext } from 'openscriptjs'; +import { context, putContext, app } from "modular-openscriptjs"; + +const h = app("h"); // Register contexts putContext(["global", "user"], "AppContext"); @@ -141,8 +137,8 @@ const gc = context("global"); // Initialize states gc.states({ - appName: "My App", - theme: "light" + appName: "My App", + theme: "light", }); // Pass to components @@ -154,10 +150,14 @@ h.MyComponent(gc.appName, { parent: document.body }); OpenScript works seamlessly with Tailwind: ```javascript +import { app } from "modular-openscriptjs"; + +const h = app("h"); + h.div( - { class: "bg-blue-500 text-white p-4 rounded-lg" }, - h.h1({ class: "text-2xl font-bold" }, "Styled with Tailwind") -) + { class: "bg-blue-500 text-white p-4 rounded-lg" }, + h.h1({ class: "text-2xl font-bold" }, "Styled with Tailwind") +); ``` See [Tailwind Integration Guide](./docs/TAILWIND_INTEGRATION.md) for details. @@ -165,7 +165,7 @@ See [Tailwind Integration Guide](./docs/TAILWIND_INTEGRATION.md) for details. ## 🔧 Building Your App ```bash -# Development +# Development npm run dev # Production build @@ -181,13 +181,11 @@ For proper minification handling: ```javascript // vite.config.js -import { openScriptComponentPlugin } from 'openscriptjs/plugin'; +import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; export default { - plugins: [ - openScriptComponentPlugin() - ] -} + plugins: [openScriptComponentPlugin()], +}; ``` This ensures component names survive minification. @@ -209,9 +207,9 @@ MIT © Levi Kamara Zwannah ## 🔗 Links -- [GitHub Repository](https://github.com/yourusername/openscriptjs) -- [Issue Tracker](https://github.com/yourusername/openscriptjs/issues) -- [npm Package](https://www.npmjs.com/package/openscriptjs) +- [GitHub Repository](https://github.com/yourusername/modular-openscriptjs) +- [Issue Tracker](https://github.com/yourusername/modular-openscriptjs/issues) +- [npm Package](https://www.npmjs.com/package/modular-openscriptjs) --- diff --git a/bin/create-ojs-app b/bin/create-ojs-app index 735a667..2156df3 100644 --- a/bin/create-ojs-app +++ b/bin/create-ojs-app @@ -85,7 +85,7 @@ async function createProject(projectName, template = "basic") { preview: "vite preview", }, dependencies: { - openscriptjs: "^1.0.0", + "modular-openscriptjs": "^1.0.0", }, devDependencies: { vite: "^5.0.7", diff --git a/examples/advanced-features.js b/examples/advanced-features.js index 4adacd7..711adbd 100644 --- a/examples/advanced-features.js +++ b/examples/advanced-features.js @@ -1,4 +1,6 @@ -import { Component, h, state, putContext, context, app } from "../index.js"; +import { Component, app, state, putContext, context } from "../index.js"; + +const h = app("h"); // 1. Fragments Example class FragmentComponent extends Component { diff --git a/examples/basic-app/events.js b/examples/basic-app/events.js index b5ee630..2b88264 100644 --- a/examples/basic-app/events.js +++ b/examples/basic-app/events.js @@ -3,7 +3,9 @@ * Centralized event catalog following OpenScript best practices */ -import { broker } from "../../index.js"; +import { app } from "../../index.js"; + +const broker = app("broker"); /** * Application Events @@ -11,56 +13,56 @@ import { broker } from "../../index.js"; * Example: app.started becomes "app:started" */ export const $e = { - app: { - started: true, - ready: true, - }, + app: { + started: true, + ready: true, + }, - todo: { - added: true, - updated: true, - deleted: true, - completed: true, - uncompleted: true, + todo: { + added: true, + updated: true, + deleted: true, + completed: true, + uncompleted: true, - needs: { - add: true, - update: true, - delete: true, - toggle: true, - filter: true, - }, + needs: { + add: true, + update: true, + delete: true, + toggle: true, + filter: true, + }, - has: { - addError: true, - updateError: true, - deleteError: true, - list: true, - } + has: { + addError: true, + updateError: true, + deleteError: true, + list: true, }, + }, - filter: { - changed: true, - cleared: true, + filter: { + changed: true, + cleared: true, - needs: { - apply: true, - clear: true, - } + needs: { + apply: true, + clear: true, }, + }, - ui: { - needs: { - modal: true, - confirm: true, - toast: true, - }, + ui: { + needs: { + modal: true, + confirm: true, + toast: true, + }, - modal: { - opened: true, - closed: true, - } - } + modal: { + opened: true, + closed: true, + }, + }, }; // Register all events with the broker diff --git a/examples/basic-app/index.js b/examples/basic-app/index.js index 1f0b073..6a637ba 100644 --- a/examples/basic-app/index.js +++ b/examples/basic-app/index.js @@ -16,7 +16,10 @@ import { loadTodosFromLocalStorage } from "./helpers.js"; import "./routes.js"; // Import OpenScript utilities -import { broker, router } from "../../index.js"; +import { app } from "../../index.js"; + +const broker = app("broker"); +const router = app("router"); // ============================================ // APPLICATION INITIALIZATION @@ -27,10 +30,10 @@ console.log("🚀 Initializing Todo App..."); // Load saved todos from localStorage const savedTodos = loadTodosFromLocalStorage(); if (savedTodos.length > 0) { - tc.todos.value = savedTodos; - // Update nextId based on loaded todos - const maxId = Math.max(...savedTodos.map(t => t.id || 0)); - tc.nextId = maxId + 1; + tc.todos.value = savedTodos; + // Update nextId based on loaded todos + const maxId = Math.max(...savedTodos.map((t) => t.id || 0)); + tc.nextId = maxId + 1; } // Emit app started event diff --git a/examples/basic-app/pages/TodoApp.js b/examples/basic-app/pages/TodoApp.js index f87c400..d26d0eb 100644 --- a/examples/basic-app/pages/TodoApp.js +++ b/examples/basic-app/pages/TodoApp.js @@ -3,181 +3,185 @@ * Main page layout for the todo application */ -import { Component, h } from "../../../index.js"; +import { Component, app } from "../../../index.js"; import { gc, tc } from "../contexts.js"; +const h = app("h"); + export default class TodoApp extends Component { - render(...args) { - return h.div( - { class: "container py-5" }, - - // Header - h.div( - { class: "row mb-4" }, - h.div( - { class: "col-12 text-center" }, - h.h1( - { class: "display-4 mb-2" }, - h.i({ class: "fas fa-check-circle text-primary me-3" }), - gc.appName.value - ), - h.p( - { class: "text-muted" }, - "A simple and elegant todo list built with OpenScript" - ) - ) - ), + render(...args) { + return h.div( + { class: "container py-5" }, + + // Header + h.div( + { class: "row mb-4" }, + h.div( + { class: "col-12 text-center" }, + h.h1( + { class: "display-4 mb-2" }, + h.i({ class: "fas fa-check-circle text-primary me-3" }), + gc.appName.value + ), + h.p( + { class: "text-muted" }, + "A simple and elegant todo list built with OpenScript" + ) + ) + ), + + // Main content area + h.div( + { class: "row" }, + h.div( + { class: "col-md-8 offset-md-2 col-lg-6 offset-lg-3" }, - // Main content area + // Card container + h.div( + { class: "card shadow-sm" }, + + // Card body h.div( - { class: "row" }, - h.div( - { class: "col-md-8 offset-md-2 col-lg-6 offset-lg-3" }, - - // Card container - h.div( - { class: "card shadow-sm" }, - - // Card body - h.div( - { class: "card-body p-4" }, - - // Todo input form placeholder - h.div( - { class: "mb-4" }, - h.div( - { class: "input-group" }, - h.input({ - type: "text", - class: "form-control form-control-lg", - placeholder: "What needs to be done?", - id: "todo-input" - }), - h.button( - { - class: "btn btn-primary", - type: "button" - }, - h.i({ class: "fas fa-plus me-2" }), - "Add" - ) - ) - ), + { class: "card-body p-4" }, - // Filter tabs - h.ul( - { class: "nav nav-pills mb-4" }, - h.li( - { class: "nav-item" }, - h.a( - { - class: "nav-link active", - href: "#" - }, - "All" - ) - ), - h.li( - { class: "nav-item" }, - h.a( - { - class: "nav-link", - href: "#" - }, - "Active" - ) - ), - h.li( - { class: "nav-item" }, - h.a( - { - class: "nav-link", - href: "#" - }, - "Completed" - ) - ) - ), + // Todo input form placeholder + h.div( + { class: "mb-4" }, + h.div( + { class: "input-group" }, + h.input({ + type: "text", + class: "form-control form-control-lg", + placeholder: "What needs to be done?", + id: "todo-input", + }), + h.button( + { + class: "btn btn-primary", + type: "button", + }, + h.i({ class: "fas fa-plus me-2" }), + "Add" + ) + ) + ), - // Todo list placeholder - h.div( - { class: "todo-list" }, - tc.todos.value.length === 0 - ? h.div( - { class: "text-center text-muted py-5" }, - h.i({ class: "fas fa-inbox fa-3x mb-3 d-block" }), - h.p("No todos yet. Add one above to get started!") - ) - : h.div( - { class: "list-group" }, - ...tc.todos.value.map(todo => - h.div( - { - class: `list-group-item d-flex align-items-center ${ - todo.completed ? 'bg-light' : '' - }` - }, - h.input({ - type: "checkbox", - class: "form-check-input me-3", - checked: todo.completed - }), - h.span( - { - class: todo.completed - ? 'text-decoration-line-through text-muted flex-grow-1' - : 'flex-grow-1' - }, - todo.text - ), - h.button( - { - class: "btn btn-sm btn-outline-danger", - type: "button" - }, - h.i({ class: "fas fa-trash" }) - ) - ) - ) - ) - ) - ), + // Filter tabs + h.ul( + { class: "nav nav-pills mb-4" }, + h.li( + { class: "nav-item" }, + h.a( + { + class: "nav-link active", + href: "#", + }, + "All" + ) + ), + h.li( + { class: "nav-item" }, + h.a( + { + class: "nav-link", + href: "#", + }, + "Active" + ) + ), + h.li( + { class: "nav-item" }, + h.a( + { + class: "nav-link", + href: "#", + }, + "Completed" + ) + ) + ), - // Card footer with stats + // Todo list placeholder + h.div( + { class: "todo-list" }, + tc.todos.value.length === 0 + ? h.div( + { class: "text-center text-muted py-5" }, + h.i({ class: "fas fa-inbox fa-3x mb-3 d-block" }), + h.p("No todos yet. Add one above to get started!") + ) + : h.div( + { class: "list-group" }, + ...tc.todos.value.map((todo) => h.div( - { class: "card-footer bg-transparent" }, - h.div( - { class: "d-flex justify-content-between align-items-center" }, - h.small( - { class: "text-muted" }, - `${tc.todos.value.filter(t => !t.completed).length} items left` - ), - h.small( - { class: "text-muted" }, - h.i({ class: "fas fa-info-circle me-1" }), - `Total: ${tc.todos.value.length}` - ) - ) + { + class: `list-group-item d-flex align-items-center ${ + todo.completed ? "bg-light" : "" + }`, + }, + h.input({ + type: "checkbox", + class: "form-check-input me-3", + checked: todo.completed, + }), + h.span( + { + class: todo.completed + ? "text-decoration-line-through text-muted flex-grow-1" + : "flex-grow-1", + }, + todo.text + ), + h.button( + { + class: "btn btn-sm btn-outline-danger", + type: "button", + }, + h.i({ class: "fas fa-trash" }) + ) ) + ) ) - ) + ) ), - // Footer + // Card footer with stats h.div( - { class: "row mt-5" }, - h.div( - { class: "col-12 text-center" }, - h.p( - { class: "text-muted small" }, - "Built with ", - h.i({ class: "fas fa-heart text-danger" }), - " using OpenScript Framework" - ) + { class: "card-footer bg-transparent" }, + h.div( + { class: "d-flex justify-content-between align-items-center" }, + h.small( + { class: "text-muted" }, + `${ + tc.todos.value.filter((t) => !t.completed).length + } items left` + ), + h.small( + { class: "text-muted" }, + h.i({ class: "fas fa-info-circle me-1" }), + `Total: ${tc.todos.value.length}` ) - ), + ) + ) + ) + ) + ), + + // Footer + h.div( + { class: "row mt-5" }, + h.div( + { class: "col-12 text-center" }, + h.p( + { class: "text-muted small" }, + "Built with ", + h.i({ class: "fas fa-heart text-danger" }), + " using OpenScript Framework" + ) + ) + ), - ...args - ); - } + ...args + ); + } } diff --git a/examples/basic-app/routes.js b/examples/basic-app/routes.js index 5a6f8f6..808b2c6 100644 --- a/examples/basic-app/routes.js +++ b/examples/basic-app/routes.js @@ -3,19 +3,22 @@ * Defines application routing using OpenScript router */ -import { router, h, dom } from "../../index.js"; +import { app, dom } from "../../index.js"; import { gc } from "./contexts.js"; import TodoApp from "./pages/TodoApp.js"; +const router = app("router"); +const h = app("h"); + /** * Helper to render a component to the root element * @param {Component} component - Component to render */ const app = (component) => { - return component({ - parent: gc.rootElement, - resetParent: true - }); + return component({ + parent: gc.rootElement, + resetParent: true, + }); }; // ============================================ @@ -29,27 +32,43 @@ router.basePath(""); router.default(() => router.to("home")); // Home route - shows all todos -router.on("/", () => { +router.on( + "/", + () => { console.log("Route: Home"); app(h.TodoApp()); -}, "home"); + }, + "home" +); // Filter routes router.prefix("filter").group(() => { - router.on("/all", () => { - console.log("Route: Filter - All"); - app(h.TodoApp()); - }, "filter.all"); - - router.on("/active", () => { - console.log("Route: Filter - Active"); - app(h.TodoApp()); - }, "filter.active"); - - router.on("/completed", () => { - console.log("Route: Filter - Completed"); - app(h.TodoApp()); - }, "filter.completed"); + router.on( + "/all", + () => { + console.log("Route: Filter - All"); + app(h.TodoApp()); + }, + "filter.all" + ); + + router.on( + "/active", + () => { + console.log("Route: Filter - Active"); + app(h.TodoApp()); + }, + "filter.active" + ); + + router.on( + "/completed", + () => { + console.log("Route: Filter - Completed"); + app(h.TodoApp()); + }, + "filter.completed" + ); }); console.log("✓ Routes registered"); diff --git a/examples/basic-usage.js b/examples/basic-usage.js index 7ca024a..f689bde 100644 --- a/examples/basic-usage.js +++ b/examples/basic-usage.js @@ -1,4 +1,4 @@ -import { app, State, Component, ojs } from "openscriptjs"; +import { app, State, Component, ojs } from "modular-openscriptjs"; // Define a State const counter = State.state(0); diff --git a/examples/component-example.js b/examples/component-example.js index 3c2bb16..f849fb4 100644 --- a/examples/component-example.js +++ b/examples/component-example.js @@ -1,4 +1,6 @@ -import { Component, h, app } from "../index.js"; +import { Component, app } from "../index.js"; + +const h = app("h"); class SenderComponent extends Component { render(...args) { diff --git a/examples/context-state-example.js b/examples/context-state-example.js index 36c7b45..8441ef8 100644 --- a/examples/context-state-example.js +++ b/examples/context-state-example.js @@ -3,7 +3,16 @@ * Demonstrates best practice: defining states in contexts and passing to components */ -import { Component, h, context, putContext, state, dom } from "openscriptjs"; +import { + Component, + app, + context, + putContext, + state, + dom, +} from "modular-openscriptjs"; + +const h = app("h"); // ============================================ // 1. INITIALIZE CONTEXTS AND STATES @@ -18,20 +27,20 @@ const uc = context("user"); // Initialize states using .states() helper pc.states({ - pageTitle: "Dashboard", - loading: false, - currentView: "home" + pageTitle: "Dashboard", + loading: false, + currentView: "home", }); uc.states({ - username: "Guest", - isAuthenticated: false, - preferences: { theme: "light" } + username: "Guest", + isAuthenticated: false, + preferences: { theme: "light" }, }); gc.states({ - appName: "MyApp", - version: "1.0.0" + appName: "MyApp", + version: "1.0.0", }); // You can also add non-reactive properties @@ -42,61 +51,57 @@ gc.apiUrl = "https://api.example.com"; // ============================================ class PageHeader extends Component { - // Receive pageTitle state as parameter - render(pageTitle, appName, ...args) { - return h.header( - { class: "page-header" }, - h.h1(pageTitle.value), // Access state via .value - h.p({ class: "app-name" }, appName.value), - ...args - ); - } + // Receive pageTitle state as parameter + render(pageTitle, appName, ...args) { + return h.header( + { class: "page-header" }, + h.h1(pageTitle.value), // Access state via .value + h.p({ class: "app-name" }, appName.value), + ...args + ); + } } class UserGreeting extends Component { - // Receive user state - render(username, ...args) { - return h.div( - { class: "greeting" }, - h.p(`Welcome, ${username.value}!`), - ...args - ); - } + // Receive user state + render(username, ...args) { + return h.div( + { class: "greeting" }, + h.p(`Welcome, ${username.value}!`), + ...args + ); + } } class ThemeToggle extends Component { - toggleTheme() { - const current = uc.preferences.value.theme; - uc.preferences.value = { - ...uc.preferences.value, - theme: current === "light" ? "dark" : "light" - }; - } - - // Receive preferences state - render(preferences, ...args) { - return h.button( - { - class: "btn btn-secondary", - listeners: { click: this.toggleTheme } - }, - `Theme: ${preferences.value.theme}`, - ...args - ); - } + toggleTheme() { + const current = uc.preferences.value.theme; + uc.preferences.value = { + ...uc.preferences.value, + theme: current === "light" ? "dark" : "light", + }; + } + + // Receive preferences state + render(preferences, ...args) { + return h.button( + { + class: "btn btn-secondary", + listeners: { click: this.toggleTheme }, + }, + `Theme: ${preferences.value.theme}`, + ...args + ); + } } class LoadingIndicator extends Component { - // Receive loading state - render(loading, ...args) { - if (!loading.value) return null; - - return h.div( - { class: "loading" }, - h.span("Loading..."), - ...args - ); - } + // Receive loading state + render(loading, ...args) { + if (!loading.value) return null; + + return h.div({ class: "loading" }, h.span("Loading..."), ...args); + } } // ============================================ @@ -104,28 +109,25 @@ class LoadingIndicator extends Component { // ============================================ class Dashboard extends Component { - render(...args) { - return h.div( - { class: "dashboard" }, - // Pass global states to header - h.PageHeader(pc.pageTitle, gc.appName), - - // Pass user state to greeting - h.UserGreeting(uc.username), - - // Pass preferences to theme toggle - h.ThemeToggle(uc.preferences), - - // Pass loading state - h.LoadingIndicator(pc.loading), - - h.div( - { class: "content" }, - h.p("Dashboard content goes here") - ), - ...args - ); - } + render(...args) { + return h.div( + { class: "dashboard" }, + // Pass global states to header + h.PageHeader(pc.pageTitle, gc.appName), + + // Pass user state to greeting + h.UserGreeting(uc.username), + + // Pass preferences to theme toggle + h.ThemeToggle(uc.preferences), + + // Pass loading state + h.LoadingIndicator(pc.loading), + + h.div({ class: "content" }, h.p("Dashboard content goes here")), + ...args + ); + } } // ============================================ @@ -134,19 +136,19 @@ class Dashboard extends Component { // Function to update page function navigateToPage(pageName) { - pc.loading.value = true; - pc.pageTitle.value = pageName; - - // Simulate async navigation - setTimeout(() => { - pc.loading.value = false; - }, 500); + pc.loading.value = true; + pc.pageTitle.value = pageName; + + // Simulate async navigation + setTimeout(() => { + pc.loading.value = false; + }, 500); } // Function to login function login(username) { - uc.username.value = username; - uc.isAuthenticated.value = true; + uc.username.value = username; + uc.isAuthenticated.value = true; } // ============================================ @@ -154,42 +156,42 @@ function login(username) { // ============================================ function initializeApp() { - // Add state listeners for logging - pc.pageTitle.listener((state) => { - console.log(`Page changed to: ${state.value}`); - }); - - uc.preferences.listener((state) => { - console.log(`Theme changed to: ${state.value.theme}`); - // Could apply theme to document here - document.body.className = `theme-${state.value.theme}`; - }); - - // Render dashboard with special attributes - const dashboard = h.Dashboard({ - parent: document.getElementById("app"), - resetParent: true // Clear existing content - }); - - // Simulate user login after 1 second - setTimeout(() => { - login("John Doe"); - }, 1000); - - // Simulate page navigation after 2 seconds - setTimeout(() => { - navigateToPage("Profile"); - }, 2000); + // Add state listeners for logging + pc.pageTitle.listener((state) => { + console.log(`Page changed to: ${state.value}`); + }); + + uc.preferences.listener((state) => { + console.log(`Theme changed to: ${state.value.theme}`); + // Could apply theme to document here + document.body.className = `theme-${state.value.theme}`; + }); + + // Render dashboard with special attributes + const dashboard = h.Dashboard({ + parent: document.getElementById("app"), + resetParent: true, // Clear existing content + }); + + // Simulate user login after 1 second + setTimeout(() => { + login("John Doe"); + }, 1000); + + // Simulate page navigation after 2 seconds + setTimeout(() => { + navigateToPage("Profile"); + }, 2000); } // Export for use -export { - Dashboard, - PageHeader, - UserGreeting, - ThemeToggle, - LoadingIndicator, - initializeApp, - navigateToPage, - login +export { + Dashboard, + PageHeader, + UserGreeting, + ThemeToggle, + LoadingIndicator, + initializeApp, + navigateToPage, + login, }; diff --git a/examples/event-handling.js b/examples/event-handling.js index e707987..a76fce6 100644 --- a/examples/event-handling.js +++ b/examples/event-handling.js @@ -1,4 +1,6 @@ -import { Mediator, Component, h, app, payload, Utils } from "../index.js"; +import { Mediator, Component, app, payload, Utils } from "../index.js"; + +const h = app("h"); // 1. Declarative Event Listening (Mediator) // Mediators are perfect for handling business logic and responding to events. diff --git a/examples/state-example.js b/examples/state-example.js index 0d68bba..e0f81b4 100644 --- a/examples/state-example.js +++ b/examples/state-example.js @@ -3,369 +3,355 @@ * Demonstrates various state patterns in OpenScript */ -import { Component, h, state } from "../index.js"; +import { Component, app, state } from "../index.js"; + +const h = app("h"); // ============================================ // 1. Basic Counter Component with State // ============================================ class Counter extends Component { - // Create state directly in the component - count = state(0); + // Create state directly in the component + count = state(0); - // Regular component methods (NOT event listeners) - increment() { - this.count.value++; - } + // Regular component methods (NOT event listeners) + increment() { + this.count.value++; + } - decrement() { - this.count.value--; - } + decrement() { + this.count.value--; + } - reset() { - this.count.value = 0; - } + reset() { + this.count.value = 0; + } - // Component automatically re-renders when state changes - render(...args) { - return h.div( - { class: "counter-container" }, - h.h3("Counter Example"), - h.p( - { class: "count-display" }, - "Count: ", - h.strong(this.count.value) - ), - h.div( - { class: "button-group" }, - // Using listeners attribute - h.button( - { - class: "btn btn-success", - listeners: { click: this.increment } - }, - "+" - ), - h.button( - { - class: "btn btn-danger", - listeners: { click: this.decrement } - }, - "-" - ), - // Alternative: using this.method() - h.button( - { - class: "btn btn-secondary", - onclick: this.method("reset") - }, - "Reset" - ) - ), - ...args - ); - } + // Component automatically re-renders when state changes + render(...args) { + return h.div( + { class: "counter-container" }, + h.h3("Counter Example"), + h.p({ class: "count-display" }, "Count: ", h.strong(this.count.value)), + h.div( + { class: "button-group" }, + // Using listeners attribute + h.button( + { + class: "btn btn-success", + listeners: { click: this.increment }, + }, + "+" + ), + h.button( + { + class: "btn btn-danger", + listeners: { click: this.decrement }, + }, + "-" + ), + // Alternative: using this.method() + h.button( + { + class: "btn btn-secondary", + onclick: this.method("reset"), + }, + "Reset" + ) + ), + ...args + ); + } } // ============================================ // 2. Todo List Component with Array State // ============================================ class TodoList extends Component { - todos = state([]); - inputValue = state(""); - - addTodo() { - if (this.inputValue.value.trim()) { - // Push new todo to the array - this.todos.value = [ - ...this.todos.value, - { - id: Date.now(), - text: this.inputValue.value, - completed: false - } - ]; - this.inputValue.value = ""; - } - } + todos = state([]); + inputValue = state(""); - toggleTodo(id) { - this.todos.value = this.todos.value.map(todo => - todo.id === id ? { ...todo, completed: !todo.completed } : todo - ); + addTodo() { + if (this.inputValue.value.trim()) { + // Push new todo to the array + this.todos.value = [ + ...this.todos.value, + { + id: Date.now(), + text: this.inputValue.value, + completed: false, + }, + ]; + this.inputValue.value = ""; } + } - deleteTodo(id) { - this.todos.value = this.todos.value.filter(todo => todo.id !== id); - } + toggleTodo(id) { + this.todos.value = this.todos.value.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo + ); + } - updateInput(e) { - this.inputValue.value = e.target.value; - } + deleteTodo(id) { + this.todos.value = this.todos.value.filter((todo) => todo.id !== id); + } - render(...args) { - return h.div( - { class: "todo-container" }, - h.h3("Todo List Example"), - - // Input form - h.div( - { class: "input-group mb-3" }, - h.input({ - type: "text", - class: "form-control", - placeholder: "Enter a todo...", - value: this.inputValue.value, - listeners: { - input: this.updateInput, - keypress: (e) => { - if (e.key === "Enter") this.addTodo(); - } - } - }), - h.button( - { - class: "btn btn-primary", - listeners: { click: this.addTodo } - }, - "Add" - ) - ), + updateInput(e) { + this.inputValue.value = e.target.value; + } - // Todo list - h.ul( - { class: "list-group" }, - ...this.todos.value.map(todo => - h.li( - { - class: "list-group-item d-flex justify-content-between align-items-center", - style: todo.completed ? "text-decoration: line-through; opacity: 0.6" : "" - }, - h.span( - { - onclick: () => this.toggleTodo(todo.id), - style: "cursor: pointer; flex: 1" - }, - todo.text - ), - h.button( - { - class: "btn btn-sm btn-danger", - onclick: () => this.deleteTodo(todo.id) - }, - "Delete" - ) - ) - ) - ), + render(...args) { + return h.div( + { class: "todo-container" }, + h.h3("Todo List Example"), + + // Input form + h.div( + { class: "input-group mb-3" }, + h.input({ + type: "text", + class: "form-control", + placeholder: "Enter a todo...", + value: this.inputValue.value, + listeners: { + input: this.updateInput, + keypress: (e) => { + if (e.key === "Enter") this.addTodo(); + }, + }, + }), + h.button( + { + class: "btn btn-primary", + listeners: { click: this.addTodo }, + }, + "Add" + ) + ), - // Stats - h.p( - { class: "mt-3" }, - `Total: ${this.todos.value.length} | `, - `Completed: ${this.todos.value.filter(t => t.completed).length}` + // Todo list + h.ul( + { class: "list-group" }, + ...this.todos.value.map((todo) => + h.li( + { + class: + "list-group-item d-flex justify-content-between align-items-center", + style: todo.completed + ? "text-decoration: line-through; opacity: 0.6" + : "", + }, + h.span( + { + onclick: () => this.toggleTodo(todo.id), + style: "cursor: pointer; flex: 1", + }, + todo.text ), - ...args - ); - } + h.button( + { + class: "btn btn-sm btn-danger", + onclick: () => this.deleteTodo(todo.id), + }, + "Delete" + ) + ) + ) + ), + + // Stats + h.p( + { class: "mt-3" }, + `Total: ${this.todos.value.length} | `, + `Completed: ${this.todos.value.filter((t) => t.completed).length}` + ), + ...args + ); + } } // ============================================ // 3. Form Component with Object State // ============================================ class UserForm extends Component { - formData = state({ - name: "", - email: "", - age: "" - }); + formData = state({ + name: "", + email: "", + age: "", + }); - submitted = state(false); + submitted = state(false); - updateField(field, value) { - this.formData.value = { - ...this.formData.value, - [field]: value - }; - } + updateField(field, value) { + this.formData.value = { + ...this.formData.value, + [field]: value, + }; + } - handleSubmit(e) { - e.preventDefault(); - console.log("Form submitted:", this.formData.value); - this.submitted.value = true; - - // Reset after 2 seconds - setTimeout(() => { - this.submitted.value = false; - }, 2000); - } + handleSubmit(e) { + e.preventDefault(); + console.log("Form submitted:", this.formData.value); + this.submitted.value = true; - render(...args) { - return h.div( - { class: "form-container" }, - h.h3("User Form Example"), - - h.form( - { listeners: { submit: this.handleSubmit } }, - h.div( - { class: "mb-3" }, - h.label({ class: "form-label" }, "Name"), - h.input({ - type: "text", - class: "form-control", - value: this.formData.value.name, - listeners: { - input: (e) => this.updateField("name", e.target.value) - } - }) - ), - h.div( - { class: "mb-3" }, - h.label({ class: "form-label" }, "Email"), - h.input({ - type: "email", - class: "form-control", - value: this.formData.value.email, - listeners: { - input: (e) => this.updateField("email", e.target.value) - } - }) - ), - h.div( - { class: "mb-3" }, - h.label({ class: "form-label" }, "Age"), - h.input({ - type: "number", - class: "form-control", - value: this.formData.value.age, - listeners: { - input: (e) => this.updateField("age", e.target.value) - } - }) - ), - h.button( - { type: "submit", class: "btn btn-primary" }, - "Submit" - ), - this.submitted.value - ? h.div( - { class: "alert alert-success mt-3" }, - "Form submitted successfully!" - ) - : null - ), - ...args - ); - } + // Reset after 2 seconds + setTimeout(() => { + this.submitted.value = false; + }, 2000); + } + + render(...args) { + return h.div( + { class: "form-container" }, + h.h3("User Form Example"), + + h.form( + { listeners: { submit: this.handleSubmit } }, + h.div( + { class: "mb-3" }, + h.label({ class: "form-label" }, "Name"), + h.input({ + type: "text", + class: "form-control", + value: this.formData.value.name, + listeners: { + input: (e) => this.updateField("name", e.target.value), + }, + }) + ), + h.div( + { class: "mb-3" }, + h.label({ class: "form-label" }, "Email"), + h.input({ + type: "email", + class: "form-control", + value: this.formData.value.email, + listeners: { + input: (e) => this.updateField("email", e.target.value), + }, + }) + ), + h.div( + { class: "mb-3" }, + h.label({ class: "form-label" }, "Age"), + h.input({ + type: "number", + class: "form-control", + value: this.formData.value.age, + listeners: { + input: (e) => this.updateField("age", e.target.value), + }, + }) + ), + h.button({ type: "submit", class: "btn btn-primary" }, "Submit"), + this.submitted.value + ? h.div( + { class: "alert alert-success mt-3" }, + "Form submitted successfully!" + ) + : null + ), + ...args + ); + } } // ============================================ // 4. State with Listeners // ============================================ class StateListenerExample extends Component { - temperature = state(20); - - constructor() { - super(); - - // Add a listener that fires whenever temperature changes - this.temperature.listener((tempState) => { - console.log(`Temperature changed to: ${tempState.value}°C`); - - // You could trigger side effects here - if (tempState.value > 30) { - console.warn("Temperature is getting high!"); - } - }); - } + temperature = state(20); - increase() { - this.temperature.value += 5; - } + constructor() { + super(); - decrease() { - this.temperature.value -= 5; - } + // Add a listener that fires whenever temperature changes + this.temperature.listener((tempState) => { + console.log(`Temperature changed to: ${tempState.value}°C`); - render(...args) { - const temp = this.temperature.value; - let status = "Normal"; - let statusClass = "badge bg-success"; - - if (temp > 30) { - status = "Hot"; - statusClass = "badge bg-danger"; - } else if (temp < 10) { - status = "Cold"; - statusClass = "badge bg-primary"; - } - - return h.div( - { class: "temperature-container" }, - h.h3("State Listener Example"), - h.p("Check console for state change logs"), - h.div( - { class: "display-4" }, - `${temp}°C `, - h.span({ class: statusClass }, status) - ), - h.div( - { class: "button-group mt-3" }, - h.button( - { - class: "btn btn-primary", - listeners: { click: this.increase } - }, - "Increase" - ), - h.button( - { - class: "btn btn-info", - listeners: { click: this.decrease } - }, - "Decrease" - ) - ), - ...args - ); + // You could trigger side effects here + if (tempState.value > 30) { + console.warn("Temperature is getting high!"); + } + }); + } + + increase() { + this.temperature.value += 5; + } + + decrease() { + this.temperature.value -= 5; + } + + render(...args) { + const temp = this.temperature.value; + let status = "Normal"; + let statusClass = "badge bg-success"; + + if (temp > 30) { + status = "Hot"; + statusClass = "badge bg-danger"; + } else if (temp < 10) { + status = "Cold"; + statusClass = "badge bg-primary"; } + + return h.div( + { class: "temperature-container" }, + h.h3("State Listener Example"), + h.p("Check console for state change logs"), + h.div( + { class: "display-4" }, + `${temp}°C `, + h.span({ class: statusClass }, status) + ), + h.div( + { class: "button-group mt-3" }, + h.button( + { + class: "btn btn-primary", + listeners: { click: this.increase }, + }, + "Increase" + ), + h.button( + { + class: "btn btn-info", + listeners: { click: this.decrease }, + }, + "Decrease" + ) + ), + ...args + ); + } } // ============================================ // 5. Demo Page - All Examples Together // ============================================ class StateDemo extends Component { - render(...args) { - return h.div( - { class: "container mt-4" }, - h.h1("OpenScript State Management Examples"), - h.hr(), - - h.div( - { class: "row" }, - h.div( - { class: "col-md-6 mb-4" }, - h.Counter() - ), - h.div( - { class: "col-md-6 mb-4" }, - h.StateListenerExample() - ) - ), + render(...args) { + return h.div( + { class: "container mt-4" }, + h.h1("OpenScript State Management Examples"), + h.hr(), - h.div( - { class: "row" }, - h.div( - { class: "col-md-6 mb-4" }, - h.TodoList() - ), - h.div( - { class: "col-md-6 mb-4" }, - h.UserForm() - ) - ), - ...args - ); - } + h.div( + { class: "row" }, + h.div({ class: "col-md-6 mb-4" }, h.Counter()), + h.div({ class: "col-md-6 mb-4" }, h.StateListenerExample()) + ), + + h.div( + { class: "row" }, + h.div({ class: "col-md-6 mb-4" }, h.TodoList()), + h.div({ class: "col-md-6 mb-4" }, h.UserForm()) + ), + ...args + ); + } } export { Counter, TodoList, UserForm, StateListenerExample, StateDemo }; diff --git a/package.json b/package.json index 5d18e28..967f5bf 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,12 @@ "version": "1.0.0", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", - "main": "./dist/openscript.umd.js", - "module": "./dist/openscript.es.js", + "main": "./dist/modular-openscriptjs.umd.js", + "module": "./dist/modular-openscriptjs.es.js", "exports": { ".": { - "import": "./dist/openscript.es.js", - "require": "./dist/openscript.umd.js" + "import": "./dist/modular-openscriptjs.es.js", + "require": "./dist/modular-openscriptjs.umd.js" }, "./styles": "./dist/styles/tailwind.css", "./plugin": "./build/vite-plugin-openscript.js" diff --git a/src/index.js b/src/index.js index 11bd133..f306db4 100644 --- a/src/index.js +++ b/src/index.js @@ -42,6 +42,7 @@ container.value("contextProvider", contextProvider); container.value("mediatorManager", mediatorManager); container.value("loader", loader); container.value("autoload", autoload); +container.value("h", h); // Global Helpers const state = State.state; @@ -73,14 +74,14 @@ const dom = DOM; /** * Resolves an instance from the container or returns the container if no instance is provided - * @param {string} instance - * @returns {Container|Object} + * @param {string} instance + * @returns {Container|Object} */ const app = (instance = null) => { - if(instance === null) return container; + if (instance === null) return container; - return container.resolve(instance); -} + return container.resolve(instance); +}; // Export everything export { @@ -102,7 +103,6 @@ export { DOMReconciler, MarkupEngine, MarkupHandler, - h, Utils, DOM, app, @@ -148,7 +148,6 @@ export default { DOMReconciler, MarkupEngine, MarkupHandler, - h, Utils, DOM, app, diff --git a/templates/basic/src/components/App.js b/templates/basic/src/components/App.js index 2bd3f45..e5a4b32 100644 --- a/templates/basic/src/components/App.js +++ b/templates/basic/src/components/App.js @@ -2,25 +2,24 @@ * Root Application Component */ -import { Component, h, ojs } from 'openscriptjs'; -import Counter from './Counter.js'; +import { Component, app, ojs } from "modular-openscriptjs"; +import Counter from "./Counter.js"; + +const h = app("h"); export default class App extends Component { - render(...args) { - return h.div( - { class: "app-container" }, - h.header( - { class: "app-header" }, - h.h1("Welcome to OpenScript!"), - h.p("A lightweight, reactive JavaScript framework") - ), - h.main( - { class: "app-main" }, - h.Counter() - ), - ...args - ); - } + render(...args) { + return h.div( + { class: "app-container" }, + h.header( + { class: "app-header" }, + h.h1("Welcome to OpenScript!"), + h.p("A lightweight, reactive JavaScript framework") + ), + h.main({ class: "app-main" }, h.Counter()), + ...args + ); + } } -ojs(App); \ No newline at end of file +ojs(App); diff --git a/templates/basic/src/components/Counter.js b/templates/basic/src/components/Counter.js index f5d79e9..af4b97f 100644 --- a/templates/basic/src/components/Counter.js +++ b/templates/basic/src/components/Counter.js @@ -2,49 +2,60 @@ * Counter Component - Simple interactive example */ -import { Component, h, ojs, state } from 'openscriptjs'; +import { Component, app, ojs, state } from "modular-openscriptjs"; + +const h = app("h"); export default class Counter extends Component { - constructor() { - super(); - this.count = state(0); - } - - increment() { - this.count.value++; - } - - decrement() { - this.count.value--; - } - - reset() { - this.count.value = 0; - } - - render(...args) { - return h.div( - { class: "counter" }, - h.h2("Counter Example"), - h.div( - { class: "counter-display" }, - h.span({ class: "count" }, this.count.value) - ), - h.div( - { class: "counter-buttons" }, - h.button({ - listeners: { click: this.decrement.bind(this) } - }, "-"), - h.button({ - listeners: { click: this.reset.bind(this) } - }, "Reset"), - h.button({ - listeners: { click: this.increment.bind(this) } - }, "+") - ), - ...args - ); - } + constructor() { + super(); + this.count = state(0); + } + + increment() { + this.count.value++; + } + + decrement() { + this.count.value--; + } + + reset() { + this.count.value = 0; + } + + render(...args) { + return h.div( + { class: "counter" }, + h.h2("Counter Example"), + h.div( + { class: "counter-display" }, + h.span({ class: "count" }, this.count.value) + ), + h.div( + { class: "counter-buttons" }, + h.button( + { + listeners: { click: this.decrement.bind(this) }, + }, + "-" + ), + h.button( + { + listeners: { click: this.reset.bind(this) }, + }, + "Reset" + ), + h.button( + { + listeners: { click: this.increment.bind(this) }, + }, + "+" + ) + ), + ...args + ); + } } ojs(Counter); diff --git a/templates/basic/src/contexts.js b/templates/basic/src/contexts.js index 08149cf..3ade0bd 100644 --- a/templates/basic/src/contexts.js +++ b/templates/basic/src/contexts.js @@ -3,7 +3,7 @@ * Using IoC Container for dependency injection */ -import { Context, context, dom, putContext, app } from "openscriptjs"; +import { Context, context, dom, putContext, app } from "modular-openscriptjs"; putContext(["global"], "AppContext"); diff --git a/templates/basic/src/main.js b/templates/basic/src/main.js index 79d646c..bd5ea51 100644 --- a/templates/basic/src/main.js +++ b/templates/basic/src/main.js @@ -5,17 +5,17 @@ // this must come first to ensure that // all events the system needs have been // registered before any component is -// initialized -import { configureApp } from './ojs.config'; -import { app } from 'openscriptjs'; -import { setupContexts } from './contexts'; -import { setupRoutes } from './routes'; +// initialized +import { configureApp } from "./ojs.config"; +import { app } from "modular-openscriptjs"; +import { setupContexts } from "./contexts"; +import { setupRoutes } from "./routes"; configureApp(); setupContexts(); setupRoutes(); // start the app -app('router').listen(); +app("router").listen(); -console.log('✓ OpenScript app initialized'); +console.log("✓ OpenScript app initialized"); diff --git a/templates/basic/src/ojs.config.js b/templates/basic/src/ojs.config.js index cba9bc5..6d0b13c 100644 --- a/templates/basic/src/ojs.config.js +++ b/templates/basic/src/ojs.config.js @@ -1,4 +1,4 @@ -import { app } from "openscriptjs"; +import { app } from "modular-openscriptjs"; import { appEvents } from "./events.js"; /*---------------------------------- @@ -6,8 +6,8 @@ import { appEvents } from "./events.js"; |---------------------------------- */ -const router = app('router'); -const broker = app('broker'); +const router = app("router"); +const broker = app("broker"); export function configureApp() { /*----------------------------------- @@ -82,4 +82,4 @@ export function configureApp() { * --------------------------------------------- */ container.value("appEvents", appEvents); -} +} diff --git a/templates/basic/src/routes.js b/templates/basic/src/routes.js index 9f71d53..55dd180 100644 --- a/templates/basic/src/routes.js +++ b/templates/basic/src/routes.js @@ -1,11 +1,14 @@ /** - * Routes for Todo App + * Route Definitions * Defines application routing using OpenScript router */ -import { router, h, dom } from "openscriptjs"; +import { app, dom } from "modular-openscriptjs"; import { gc } from "./contexts.js"; +const router = app("router"); +const h = app("h"); + export function setupRoutes() { // Default route - redirect to home router.default(() => router.to("home")); @@ -14,7 +17,7 @@ export function setupRoutes() { * Helper to render a component to the root element * @param {Component} component - Component to render */ - const app = (component) => { + const appRender = (component) => { return h.App(component, { parent: gc.rootElement, resetParent: true, // Clear parent before rendering @@ -25,7 +28,7 @@ export function setupRoutes() { "/", () => { console.log("Route: Home"); - app(h.div("Hello OpenScript")); + appRender(h.div("Hello OpenScript")); }, "home" ); diff --git a/templates/bootstrap/src/components/App.js b/templates/bootstrap/src/components/App.js index 0603990..613a96c 100644 --- a/templates/bootstrap/src/components/App.js +++ b/templates/bootstrap/src/components/App.js @@ -2,54 +2,62 @@ * Root Application Component with Bootstrap */ -import { Component, h, ojs } from 'openscriptjs'; +import { Component, app, ojs } from "modular-openscriptjs"; + +const h = app("h"); export default class App extends Component { - render(...args) { - return h.div( - { class: "min-vh-100 bg-light" }, - - // Header with gradient background - h.header( - { class: "bg-gradient text-white text-center py-5", style: "background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);" }, - h.div( - { class: "container" }, - h.h1({ class: "display-4 fw-bold mb-3" }, - h.i({ class: "fas fa-rocket me-3" }), - "Welcome to OpenScript!" - ), - h.p({ class: "lead" }, "A lightweight, reactive JavaScript framework built with Bootstrap") - ) - ), - - // Main content - h.main( - { class: "container py-5" }, - h.div( - { class: "row justify-content-center" }, - h.div( - { class: "col-md-8 col-lg-6" }, - h.Counter() - ) - ) - ), - - // Footer - h.footer( - { class: "bg-dark text-white text-center py-4 mt-5" }, - h.div( - { class: "container" }, - h.p({ class: "mb-0" }, - "Built with ", - h.i({ class: "fas fa-heart text-danger" }), - " using OpenScript & Bootstrap" - ) - ) - ), - - ...args - ); - } + render(...args) { + return h.div( + { class: "min-vh-100 bg-light" }, + + // Header with gradient background + h.header( + { + class: "bg-gradient text-white text-center py-5", + style: + "background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);", + }, + h.div( + { class: "container" }, + h.h1( + { class: "display-4 fw-bold mb-3" }, + h.i({ class: "fas fa-rocket me-3" }), + "Welcome to OpenScript!" + ), + h.p( + { class: "lead" }, + "A lightweight, reactive JavaScript framework built with Bootstrap" + ) + ) + ), + + // Main content + h.main( + { class: "container py-5" }, + h.div( + { class: "row justify-content-center" }, + h.div({ class: "col-md-8 col-lg-6" }, h.Counter()) + ) + ), + + // Footer + h.footer( + { class: "bg-dark text-white text-center py-4 mt-5" }, + h.div( + { class: "container" }, + h.p( + { class: "mb-0" }, + "Built with ", + h.i({ class: "fas fa-heart text-danger" }), + " using OpenScript & Bootstrap" + ) + ) + ), + + ...args + ); + } } ojs(App); diff --git a/templates/bootstrap/src/components/Counter.js b/templates/bootstrap/src/components/Counter.js index 70f677f..8d0e650 100644 --- a/templates/bootstrap/src/components/Counter.js +++ b/templates/bootstrap/src/components/Counter.js @@ -2,105 +2,115 @@ * Counter Component with Bootstrap - Simple interactive example */ -import { Component, h, ojs, state } from 'openscriptjs'; +import { Component, app, ojs, state } from "modular-openscriptjs"; + +const h = app("h"); export default class Counter extends Component { - constructor() { - super(); - this.count = state(0); - } - - increment() { - this.count.value++; - } - - decrement() { - this.count.value--; - } - - reset() { - this.count.value = 0; - } - - get badgeClass() { - if (this.count.value > 0) return 'bg-success'; - if (this.count.value < 0) return 'bg-danger'; - return 'bg-secondary'; - } - - render(...args) { - return h.div( - { class: "card shadow-lg border-0" }, - - // Card header - h.div( - { class: "card-header bg-primary text-white" }, - h.h3({ class: "mb-0 d-flex align-items-center justify-content-center" }, - h.i({ class: "fas fa-calculator me-2" }), - "Counter Example" - ) - ), - - // Card body - h.div( - { class: "card-body text-center py-5" }, - - // Count display with badge - h.div({ class: "mb-4" }, - h.span( - { class: `badge ${this.badgeClass} fs-1 px-5 py-3` }, - this.count.value - ) - ), - - // Progress bar - h.div({ class: "progress mb-4", style: "height: 10px;" }, - h.div({ - class: `progress-bar ${this.count.value >= 0 ? 'bg-success' : 'bg-danger'}`, - style: `width: ${Math.min(Math.abs(this.count.value) * 10, 100)}%`, - role: "progressbar" - }) - ), - - // Button group - h.div( - { class: "btn-group" , role: "group" }, - h.button({ - class: "btn btn-outline-danger btn-lg", - listeners: { click: this.decrement.bind(this) } - }, - h.i({ class: "fas fa-minus me-2" }), - "Decrement" - ), - h.button({ - class: "btn btn-outline-secondary btn-lg", - listeners: { click: this.reset.bind(this) } - }, - h.i({ class: "fas fa-redo me-2" }), - "Reset" - ), - h.button({ - class: "btn btn-outline-success btn-lg", - listeners: { click: this.increment.bind(this) } - }, - h.i({ class: "fas fa-plus me-2" }), - "Increment" - ) - ) - ), - - // Card footer - h.div( - { class: "card-footer text-muted text-center" }, - h.small( - h.i({ class: "fas fa-info-circle me-1" }), - "Click the buttons to update the counter" - ) - ), - - ...args - ); - } + constructor() { + super(); + this.count = state(0); + } + + increment() { + this.count.value++; + } + + decrement() { + this.count.value--; + } + + reset() { + this.count.value = 0; + } + + get badgeClass() { + if (this.count.value > 0) return "bg-success"; + if (this.count.value < 0) return "bg-danger"; + return "bg-secondary"; + } + + render(...args) { + return h.div( + { class: "card shadow-lg border-0" }, + + // Card header + h.div( + { class: "card-header bg-primary text-white" }, + h.h3( + { class: "mb-0 d-flex align-items-center justify-content-center" }, + h.i({ class: "fas fa-calculator me-2" }), + "Counter Example" + ) + ), + + // Card body + h.div( + { class: "card-body text-center py-5" }, + + // Count display with badge + h.div( + { class: "mb-4" }, + h.span( + { class: `badge ${this.badgeClass} fs-1 px-5 py-3` }, + this.count.value + ) + ), + + // Progress bar + h.div( + { class: "progress mb-4", style: "height: 10px;" }, + h.div({ + class: `progress-bar ${ + this.count.value >= 0 ? "bg-success" : "bg-danger" + }`, + style: `width: ${Math.min(Math.abs(this.count.value) * 10, 100)}%`, + role: "progressbar", + }) + ), + + // Button group + h.div( + { class: "btn-group", role: "group" }, + h.button( + { + class: "btn btn-outline-danger btn-lg", + listeners: { click: this.decrement.bind(this) }, + }, + h.i({ class: "fas fa-minus me-2" }), + "Decrement" + ), + h.button( + { + class: "btn btn-outline-secondary btn-lg", + listeners: { click: this.reset.bind(this) }, + }, + h.i({ class: "fas fa-redo me-2" }), + "Reset" + ), + h.button( + { + class: "btn btn-outline-success btn-lg", + listeners: { click: this.increment.bind(this) }, + }, + h.i({ class: "fas fa-plus me-2" }), + "Increment" + ) + ) + ), + + // Card footer + h.div( + { class: "card-footer text-muted text-center" }, + h.small( + h.i({ class: "fas fa-info-circle me-1" }), + "Click the buttons to update the counter" + ) + ), + + ...args + ); + } } ojs(Counter); diff --git a/templates/bootstrap/src/contexts.js b/templates/bootstrap/src/contexts.js index 94549d5..febd902 100644 --- a/templates/bootstrap/src/contexts.js +++ b/templates/bootstrap/src/contexts.js @@ -3,7 +3,7 @@ * Using IoC Container for dependency injection */ -import { Context, context, dom, putContext, app } from "openscriptjs"; +import { Context, context, dom, putContext, app } from "modular-openscriptjs"; putContext(["global"], "AppContext"); diff --git a/templates/bootstrap/src/main.js b/templates/bootstrap/src/main.js index b117cf1..23052a4 100644 --- a/templates/bootstrap/src/main.js +++ b/templates/bootstrap/src/main.js @@ -7,7 +7,7 @@ // registered before any component is // initialized import { configureApp } from "./ojs.config.js"; -import { app } from "openscriptjs"; +import { app } from "modular-openscriptjs"; import { setupContexts } from "./contexts.js"; import { setupRoutes } from "./routes.js"; import "./style.scss"; // Import Bootstrap styles diff --git a/templates/bootstrap/src/ojs.config.js b/templates/bootstrap/src/ojs.config.js index a578137..1d108c6 100644 --- a/templates/bootstrap/src/ojs.config.js +++ b/templates/bootstrap/src/ojs.config.js @@ -1,4 +1,4 @@ -import { app } from "openscriptjs"; +import { app } from "modular-openscriptjs"; import { appEvents } from "./events.js"; /*---------------------------------- diff --git a/templates/bootstrap/src/routes.js b/templates/bootstrap/src/routes.js index 12aeecd..b598600 100644 --- a/templates/bootstrap/src/routes.js +++ b/templates/bootstrap/src/routes.js @@ -3,9 +3,12 @@ * Defines application routing using OpenScript router */ -import { router, h, dom } from "openscriptjs"; +import { app, dom } from "modular-openscriptjs"; import { gc } from "./contexts.js"; +const router = app("router"); +const h = app("h"); + export function setupRoutes() { // Default route - redirect to home router.default(() => router.to("home")); @@ -14,7 +17,7 @@ export function setupRoutes() { * Helper to render a component to the root element * @param {Component} component - Component to render */ - const app = (component) => { + const appRender = (component) => { return h.App(component, { parent: gc.rootElement, resetParent: true, // Clear parent before rendering @@ -25,7 +28,7 @@ export function setupRoutes() { "/", () => { console.log("Route: Home"); - app(h.Counter()); + appRender(h.Counter()); }, "home" ); diff --git a/templates/tailwind/src/components/App.js b/templates/tailwind/src/components/App.js index f07617f..ce91a4f 100644 --- a/templates/tailwind/src/components/App.js +++ b/templates/tailwind/src/components/App.js @@ -2,24 +2,26 @@ * Root Application Component with Tailwind */ -import { Component, h, ojs } from 'openscriptjs'; +import { Component, app, ojs } from "modular-openscriptjs"; + +const h = app("h"); export default class App extends Component { - render(...args) { - return h.div( - { class: "min-h-screen bg-gradient-to-br from-purple-500 to-pink-500" }, - h.header( - { class: "text-white text-center py-12" }, - h.h1({ class: "text-5xl font-bold mb-2" }, "Welcome to OpenScript!"), - h.p({ class: "text-xl opacity-90" }, "A lightweight, reactive JavaScript framework") - ), - h.main( - { class: "flex justify-center items-center py-12" }, - h.Counter() - ), - ...args - ); - } + render(...args) { + return h.div( + { class: "min-h-screen bg-gradient-to-br from-purple-500 to-pink-500" }, + h.header( + { class: "text-white text-center py-12" }, + h.h1({ class: "text-5xl font-bold mb-2" }, "Welcome to OpenScript!"), + h.p( + { class: "text-xl opacity-90" }, + "A lightweight, reactive JavaScript framework" + ) + ), + h.main({ class: "flex justify-center items-center py-12" }, h.Counter()), + ...args + ); + } } -ojs(App); \ No newline at end of file +ojs(App); diff --git a/templates/tailwind/src/components/Counter.js b/templates/tailwind/src/components/Counter.js index 6bfd407..6aae20c 100644 --- a/templates/tailwind/src/components/Counter.js +++ b/templates/tailwind/src/components/Counter.js @@ -2,52 +2,69 @@ * Counter Component with Tailwind - Simple interactive example */ -import { Component, h, ojs, state } from 'openscriptjs'; +import { Component, app, ojs, state } from "modular-openscriptjs"; + +const h = app("h"); export default class Counter extends Component { - constructor() { - super(); - this.count = state(0); - } - - increment() { - this.count.value++; - } - - decrement() { - this.count.value--; - } - - reset() { - this.count.value = 0; - } - - render(...args) { - return h.div( - { class: "bg-white rounded-2xl shadow-2xl p-8 min-w-[300px]" }, - h.h2({ class: "text-3xl font-bold text-purple-600 mb-6 text-center" }, "Counter Example"), - h.div( - { class: "my-8 text-center" }, - h.span({ class: "text-6xl font-bold text-gray-800" }, this.count.value) - ), - h.div( - { class: "flex gap-4 justify-center" }, - h.button({ - class: "px-6 py-3 bg-purple-500 text-white rounded-lg font-semibold hover:bg-purple-600 active:scale-95 transition-all", - listeners: { click: this.decrement.bind(this) } - }, "-"), - h.button({ - class: "px-6 py-3 bg-gray-500 text-white rounded-lg font-semibold hover:bg-gray-600 active:scale-95 transition-all", - listeners: { click: this.reset.bind(this) } - }, "Reset"), - h.button({ - class: "px-6 py-3 bg-purple-500 text-white rounded-lg font-semibold hover:bg-purple-600 active:scale-95 transition-all", - listeners: { click: this.increment.bind(this) } - }, "+") - ), - ...args - ); - } + constructor() { + super(); + this.count = state(0); + } + + increment() { + this.count.value++; + } + + decrement() { + this.count.value--; + } + + reset() { + this.count.value = 0; + } + + render(...args) { + return h.div( + { class: "bg-white rounded-2xl shadow-2xl p-8 min-w-[300px]" }, + h.h2( + { class: "text-3xl font-bold text-purple-600 mb-6 text-center" }, + "Counter Example" + ), + h.div( + { class: "my-8 text-center" }, + h.span({ class: "text-6xl font-bold text-gray-800" }, this.count.value) + ), + h.div( + { class: "flex gap-4 justify-center" }, + h.button( + { + class: + "px-6 py-3 bg-purple-500 text-white rounded-lg font-semibold hover:bg-purple-600 active:scale-95 transition-all", + listeners: { click: this.decrement.bind(this) }, + }, + "-" + ), + h.button( + { + class: + "px-6 py-3 bg-gray-500 text-white rounded-lg font-semibold hover:bg-gray-600 active:scale-95 transition-all", + listeners: { click: this.reset.bind(this) }, + }, + "Reset" + ), + h.button( + { + class: + "px-6 py-3 bg-purple-500 text-white rounded-lg font-semibold hover:bg-purple-600 active:scale-95 transition-all", + listeners: { click: this.increment.bind(this) }, + }, + "+" + ) + ), + ...args + ); + } } ojs(Counter); diff --git a/templates/tailwind/src/contexts.js b/templates/tailwind/src/contexts.js index e3141d5..4daae41 100644 --- a/templates/tailwind/src/contexts.js +++ b/templates/tailwind/src/contexts.js @@ -3,7 +3,7 @@ * Using IoC Container for dependency injection */ -import { Context, context, dom, putContext, app } from "openscriptjs"; +import { Context, context, dom, putContext, app } from "modular-openscriptjs"; putContext(["global"], "AppContext"); diff --git a/templates/tailwind/src/main.js b/templates/tailwind/src/main.js index 7fd9e61..b848943 100644 --- a/templates/tailwind/src/main.js +++ b/templates/tailwind/src/main.js @@ -11,7 +11,7 @@ // registered before any component is // initialized import { configureApp } from "./ojs.config.js"; -import { app } from "openscriptjs"; +import { app } from "modular-openscriptjs"; import { setupContexts } from "./contexts.js"; import { setupRoutes } from "./routes.js"; import "./style.css"; // Import Tailwind styles diff --git a/templates/tailwind/src/ojs.config.js b/templates/tailwind/src/ojs.config.js index a578137..1d108c6 100644 --- a/templates/tailwind/src/ojs.config.js +++ b/templates/tailwind/src/ojs.config.js @@ -1,4 +1,4 @@ -import { app } from "openscriptjs"; +import { app } from "modular-openscriptjs"; import { appEvents } from "./events.js"; /*---------------------------------- diff --git a/templates/tailwind/src/routes.js b/templates/tailwind/src/routes.js index 4b8b573..fd855ff 100644 --- a/templates/tailwind/src/routes.js +++ b/templates/tailwind/src/routes.js @@ -3,9 +3,12 @@ * Defines application routing using OpenScript router */ -import { router, h, dom } from "openscriptjs"; +import { app, dom } from "modular-openscriptjs"; import { gc } from "./contexts.js"; +const router = app("router"); +const h = app("h"); + export function setupRoutes() { // Default route - redirect to home router.default(() => router.to("home")); @@ -14,7 +17,7 @@ export function setupRoutes() { * Helper to render a component to the root element * @param {Component} component - Component to render */ - const app = (component) => { + const appRender = (component) => { return h.App(component, { parent: gc.rootElement, resetParent: true, // Clear parent before rendering @@ -25,7 +28,7 @@ export function setupRoutes() { "/", () => { console.log("Route: Home"); - app(h.Counter()); + appRender(h.Counter()); }, "home" ); diff --git a/vite.config.js b/vite.config.js index 73c6dc9..cafebd6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,7 +17,7 @@ export default defineConfig({ name: "OpenScript", // Output formats formats: ["es", "umd"], - fileName: (format) => `openscript.${format}.js`, + fileName: (format) => `modular-openscriptjs.${format}.js`, }, rollupOptions: { From a836c39d6a229895e4714e1f76c52c64a02837ad Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 01:46:29 +0300 Subject: [PATCH 08/46] removed security vulnerablility --- README.npm.md | 801 ++++++++++++++++++++++++++++++++++++++++++++------ package.json | 26 +- 2 files changed, 723 insertions(+), 104 deletions(-) diff --git a/README.npm.md b/README.npm.md index 407e947..ce954f5 100644 --- a/README.npm.md +++ b/README.npm.md @@ -1,33 +1,51 @@ -# OpenScriptJs +# Modular OpenScript Framework [![npm version](https://badge.fury.io/js/modular-openscriptjs.svg)](https://www.npmjs.com/package/modular-openscriptjs) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -A lightweight, reactive JavaScript framework for building modern web applications with components, state management, routing, and event-driven architecture. +A modern, lightweight, reactive JavaScript framework for building scalable web applications with **zero runtime dependencies**. OpenScript combines IoC, reactive state management, and component-based architecture into a powerful yet simple package. -## ✨ Features +## ✨ Why OpenScript? -- ⚡️ **Reactive State Management** - Built-in reactive state with automatic component re-rendering -- 🧩 **Component-Based** - Modular, reusable components with declarative markup -- 🔄 **Routing** - Fluent client-side router API -- 📡 **Event System** - Broker pattern for decoupled component communication -- 🎯 **Mediators** - Centralized business logic handlers -- 🎨 **TailwindCSS Ready** - First-class Tailwind integration -- 🛠️ **Build Tools** - Vite plugin for minification-safe builds -- 📦 **Zero Dependencies** - Core framework has no runtime dependencies +- ⚡️ **Reactive State** - Automatic UI updates with proxy-based state +- 🧩 **Component-Based** - Modular, reusable components with lifecycle hooks +- 🔄 **Client-Side Routing** - Fluent API with parameters and nested routes +- 📡 **Event-Driven** - Broker/Mediator pattern for decoupled architecture +- 🎯 **IoC Container** - Centralized dependency management +- 🎨 **Framework Agnostic** - Works with Tailwind, Bootstrap, or vanilla CSS +- 🛠️ **Vite Plugin** - Production-ready build tools +- 📦 **Zero Dependencies** - No runtime dependencies + +--- ## 🚀 Quick Start ### Installation -````bash +```bash +npm install modular-openscriptjs +``` + +### Scaffold a New Project + +The fastest way to start: + +```bash +npx create-ojs-app my-app +cd my-app +npm run dev +``` + +**Choose from templates:** + - `basic` - Clean starter with vanilla CSS -- `tailwind` - Pre-configured with TailwindCSS +- `tailwind` - TailwindCSS with responsive design +- `bootstrap` - Bootstrap 5 integration -## 📖 Basic Usage +### Your First Component ```javascript -import { Component, app, state } from "modular-openscriptjs"; +import { Component, app, state, ojs } from "modular-openscriptjs"; const h = app("h"); @@ -44,52 +62,64 @@ class Counter extends Component { render() { return h.div( h.h2("Count: ", this.count.value), - h.button( - { - listeners: { click: this.increment.bind(this) }, - }, - "Increment" - ) + h.button({ onclick: this.increment.bind(this) }, "Increment") ); } } -// Mount and render -const counter = new Counter(); -await counter.mount(); -h.Counter({ parent: document.body }); -```` +ojs(Counter); +``` -## 🏗️ Project Structure +--- -``` -my-app/ -├── src/ -│ ├── components/ # Your components -│ ├── main.js # Entry point -│ └── style.css # Styles -├── index.html -├── vite.config.js -└── package.json -``` +## 📖 Core Concepts -## 📚 Core Concepts +### 1. Components -### Components +**Class Components** with lifecycle hooks: ```javascript import { Component, app } from "modular-openscriptjs"; const h = app("h"); -class MyComponent extends Component { +class UserCard extends Component { + async mount() { + // Called when component mounts + console.log("Component mounted"); + } + + unmount() { + // Called when component unmounts + console.log("Component unmounted"); + } + render(...args) { - return h.div({ class: "container" }, h.h1("Hello OpenScript!"), ...args); + return h.div( + { class: "card" }, + h.h3("User Profile"), + h.p("Content here"), + ...args + ); } } ``` -### State Management +**Functional Components** for simple UI: + +```javascript +const Button = (text, onClick) => { + return h.button({ onclick: onClick }, text); +}; + +const Card = (title, content) => { + return h.div({ class: "card" }, h.h2(title), h.div(content)); +}; +``` + +### 2. Reactive State + +State automatically triggers re-renders: ```javascript import { state } from "modular-openscriptjs"; @@ -97,14 +127,73 @@ import { state } from "modular-openscriptjs"; // Create reactive state const count = state(0); -// Update triggers re-render -count.value = 10; +// Read value +console.log(count.value); // 0 + +// Update value (triggers re-render) +count.value++; // Listen to changes -count.listener((s) => console.log("New value:", s.value)); +count.listener((s) => { + console.log("New:", s.value); + console.log("Previous:", s.previousValue); +}); ``` -### Routing +**State in Components:** + +```javascript +class TodoList extends Component { + constructor() { + super(); + this.todos = state([]); + } + + addTodo(text) { + this.todos.value = [ + ...this.todos.value, + { id: Date.now(), text, done: false }, + ]; + } + + toggleTodo(id) { + this.todos.value = this.todos.value.map((t) => + t.id === id ? { ...t, done: !t.done } : t + ); + } + + render() { + return h.div( + h.input({ + placeholder: "Add todo...", + onkeypress: (e) => { + if (e.key === "Enter") { + this.addTodo(e.target.value); + e.target.value = ""; + } + }, + }), + h.ul( + ...this.todos.value.map((todo) => + h.li( + { class: todo.done ? "done" : "" }, + h.input({ + type: "checkbox", + checked: todo.done, + onchange: () => this.toggleTodo(todo.id), + }), + h.span(todo.text) + ) + ) + ) + ); + } +} +``` + +### 3. Routing + +Simple yet powerful client-side routing: ```javascript import { app } from "modular-openscriptjs"; @@ -112,18 +201,71 @@ import { app } from "modular-openscriptjs"; const router = app("router"); const h = app("h"); -router.on("/home", () => { - h.HomePage({ parent: document.body, resetParent: true }); -}); +// Basic routes +router.on( + "/", + () => { + h.HomePage({ parent: document.body, resetParent: true }); + }, + "home" +); + +router.on( + "/about", + () => { + h.AboutPage({ parent: document.body, resetParent: true }); + }, + "about" +); + +// Routes with parameters +router.on( + "/users/{id}", + () => { + const userId = router.params.id; + h.UserProfile(userId, { parent: document.body, resetParent: true }); + }, + "users.view" +); -router.on("/about", () => { - h.AboutPage({ parent: document.body, resetParent: true }); +// Grouped routes +router.prefix("admin").group(() => { + router.on( + "/dashboard", + () => { + h.AdminDashboard({ parent: document.body, resetParent: true }); + }, + "admin.dashboard" + ); + + router.on( + "/users", + () => { + h.AdminUsers({ parent: document.body, resetParent: true }); + }, + "admin.users" + ); }); router.listen(); ``` -### Context & Global State +**Navigation:** + +```javascript +// Navigate to named route +router.to("home"); + +// Navigate with parameters +router.push("/users/123"); + +// Go back +router.back(); +``` + +### 4. Global State with Contexts + +Share state across your entire application: ```javascript import { context, putContext, app } from "modular-openscriptjs"; @@ -131,86 +273,563 @@ import { context, putContext, app } from "modular-openscriptjs"; const h = app("h"); // Register contexts -putContext(["global", "user"], "AppContext"); - -const gc = context("global"); +putContext(["app", "user"], "AppContext"); -// Initialize states -gc.states({ - appName: "My App", +// Initialize state +const ac = context("app"); +ac.states({ theme: "light", + language: "en", +}); + +const uc = context("user"); +uc.states({ + name: "Guest", + isLoggedIn: false, +}); + +// Use in any component +class Header extends Component { + toggleTheme() { + ac.theme.value = ac.theme.value === "light" ? "dark" : "light"; + } + + render() { + return h.header( + h.h1(`Welcome, ${uc.name.value}`), + h.button( + { onclick: this.toggleTheme.bind(this) }, + `Theme: ${ac.theme.value}` + ) + ); + } +} +``` + +### 5. Event System + +Decouple business logic with events: + +```javascript +import { Mediator, app, payload, Utils } from "modular-openscriptjs"; + +const broker = app("broker"); + +// Register events +broker.registerEvents({ + user: { + login: true, + logout: true, + }, + notification: { + show: true, + }, }); -// Pass to components -h.MyComponent(gc.appName, { parent: document.body }); +// Create mediator for business logic +class UserMediator extends Mediator { + $$user = { + login: (ed, event) => { + const data = Utils.parsePayload(ed); + console.log("User logged in:", data.message); + + // Update UI + broker.send( + "notification:show", + payload({ + message: "Login successful!", + }) + ); + }, + + logout: (ed, event) => { + console.log("User logged out"); + + broker.send( + "notification:show", + payload({ + message: "Goodbye!", + }) + ); + }, + }; +} + +new UserMediator(); + +// Emit events from anywhere +class LoginButton extends Component { + handleLogin() { + broker.send( + "user:login", + payload({ + username: "john_doe", + id: 123, + }) + ); + } + + render() { + return h.button({ onclick: this.handleLogin.bind(this) }, "Login"); + } +} ``` -## 🎨 TailwindCSS Integration +### 6. IoC Container -OpenScript works seamlessly with Tailwind: +Access services through the container: ```javascript import { app } from "modular-openscriptjs"; +// Get services const h = app("h"); +const router = app("router"); +const broker = app("broker"); +const contextProvider = app("contextProvider"); -h.div( - { class: "bg-blue-500 text-white p-4 rounded-lg" }, - h.h1({ class: "text-2xl font-bold" }, "Styled with Tailwind") -); +// Register custom values +app().value("apiUrl", "https://api.example.com"); +app().value("config", { debug: true }); + +// Access custom values +const apiUrl = app("apiUrl"); +const config = app("config"); ``` -See [Tailwind Integration Guide](./docs/TAILWIND_INTEGRATION.md) for details. +--- + +## 🏗️ Project Structure + +Typical project layout: + +``` +my-app/ +├── src/ +│ ├── components/ +│ │ ├── Header.js +│ │ ├── Footer.js +│ │ └── TodoList.js +│ ├── contexts.js # Global state +│ ├── routes.js # Route definitions +│ ├── events.js # Event registry +│ ├── mediators/ # Business logic +│ │ └── UserMediator.js +│ ├── main.js # Entry point +│ └── style.css +├── index.html +├── vite.config.js +└── package.json +``` + +--- -## 🔧 Building Your App +## 🎨 Framework Integration + +### TailwindCSS + +```javascript +import { app } from "modular-openscriptjs"; + +const h = app("h"); + +class Card extends Component { + render() { + return h.div( + { class: "bg-white rounded-lg shadow-lg p-6" }, + h.h2({ class: "text-2xl font-bold mb-4" }, "Card Title"), + h.p({ class: "text-gray-600" }, "Card content here") + ); + } +} +``` + +### Bootstrap + +```javascript +class Alert extends Component { + render(message, type = "info") { + return h.div({ class: `alert alert-${type}` }, message); + } +} +``` + +--- + +## 🔧 Build Configuration + +### Vite Setup + +```javascript +// vite.config.js +import { defineConfig } from "vite"; +import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; + +export default defineConfig({ + plugins: [openScriptComponentPlugin()], + build: { + target: "es2015", + minify: "terser", + }, +}); +``` + +This plugin ensures component names survive minification. + +### Build Commands ```bash -# Development +# Development server npm run dev # Production build npm run build -# Preview build +# Preview production build npm run preview ``` -## 📦 Using the Vite Plugin +--- + +## 💡 Advanced Features + +### Fragments -For proper minification handling: +Return multiple elements without a wrapper: ```javascript -// vite.config.js -import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; +class List extends Component { + render() { + return h.$( + // Fragment + h.h1("Title"), + h.p("Paragraph 1"), + h.p("Paragraph 2") + ); + } +} +``` -export default { - plugins: [openScriptComponentPlugin()], -}; +### State Listeners + +React to state changes: + +```javascript +const count = state(0); + +count.listener((s) => { + console.log(`Count changed from ${s.previousValue} to ${s.value}`); + + if (s.value > 10) { + console.warn("Count is getting high!"); + } +}); ``` -This ensures component names survive minification. +### Multi-Event Listeners -## 📘 Documentation +Listen to multiple events: -- [Full Documentation](./README.md) -- [API Reference](./docs/) -- [Examples](./examples/) -- [Tailwind Integration](./docs/TAILWIND_INTEGRATION.md) +```javascript +class NotificationMediator extends Mediator { + $$user = { + // Triggers on BOTH login AND logout + login_logout: (ed, event) => { + console.log(`User event: ${event}`); + this.showNotification(`User ${event.split(":")[1]}`); + }, + }; +} +``` -## 🤝 Contributing +### Computed Properties -Contributions are welcome! Please feel free to submit a Pull Request. +Use getters for derived state: -## 📄 License +```javascript +class TodoList extends Component { + constructor() { + super(); + this.todos = state([]); + } -MIT © Levi Kamara Zwannah + get completedCount() { + return this.todos.value.filter((t) => t.done).length; + } + + get activeCount() { + return this.todos.value.length - this.completedCount; + } -## 🔗 Links + render() { + return h.div( + h.p(`${this.activeCount} active, ${this.completedCount} completed`) + // ... rest of render + ); + } +} +``` + +--- + +## 📚 Complete Example + +Here's a full-featured app: + +```javascript +import { + Component, + app, + state, + context, + putContext, + Mediator, + payload, + ojs, +} from "modular-openscriptjs"; + +const h = app("h"); +const broker = app("broker"); + +// Setup context +putContext("todos", "TodoContext"); +const tc = context("todos"); +tc.states({ todos: [], filter: "all" }); + +// Register events +broker.registerEvents({ + todo: { + added: true, + removed: true, + toggled: true, + }, +}); + +// Business logic mediator +class TodoMediator extends Mediator { + $$todo = { + added: (ed) => { + console.log("Todo added:", ed); + }, + + removed: (ed) => { + console.log("Todo removed:", ed); + }, + + toggled: (ed) => { + console.log("Todo toggled:", ed); + }, + }; +} -- [GitHub Repository](https://github.com/yourusername/modular-openscriptjs) +new TodoMediator(); + +// Main component +class TodoApp extends Component { + constructor() { + super(); + this.input = state(""); + } + + addTodo() { + if (this.input.value.trim()) { + const todo = { + id: Date.now(), + text: this.input.value, + done: false, + }; + + tc.todos.value = [...tc.todos.value, todo]; + broker.send("todo:added", payload(todo)); + this.input.value = ""; + } + } + + toggleTodo(id) { + tc.todos.value = tc.todos.value.map((t) => + t.id === id ? { ...t, done: !t.done } : t + ); + broker.send("todo:toggled", payload({ id })); + } + + removeTodo(id) { + tc.todos.value = tc.todos.value.filter((t) => t.id !== id); + broker.send("todo:removed", payload({ id })); + } + + get filteredTodos() { + switch (tc.filter.value) { + case "active": + return tc.todos.value.filter((t) => !t.done); + case "done": + return tc.todos.value.filter((t) => t.done); + default: + return tc.todos.value; + } + } + + render() { + return h.div( + { class: "todo-app" }, + + // Header + h.header( + h.h1("My Todos"), + h.div( + { class: "input-group" }, + h.input({ + value: this.input.value, + placeholder: "What needs to be done?", + oninput: (e) => (this.input.value = e.target.value), + onkeypress: (e) => e.key === "Enter" && this.addTodo(), + }), + h.button({ onclick: () => this.addTodo() }, "Add") + ) + ), + + // Filters + h.div( + { class: "filters" }, + ...["all", "active", "done"].map((f) => + h.button( + { + class: tc.filter.value === f ? "active" : "", + onclick: () => (tc.filter.value = f), + }, + f.toUpperCase() + ) + ) + ), + + // Todo list + h.ul( + ...this.filteredTodos.map((todo) => + h.li( + h.input({ + type: "checkbox", + checked: todo.done, + onchange: () => this.toggleTodo(todo.id), + }), + h.span({ class: todo.done ? "done" : "" }, todo.text), + h.button({ onclick: () => this.removeTodo(todo.id) }, "×") + ) + ) + ), + + // Stats + h.footer(h.span(`${this.filteredTodos.length} items`)) + ); + } +} + +ojs(TodoApp); +``` + +--- + +## 📖 API Reference + +### Core Exports + +| Export | Type | Description | +| ------------ | -------- | --------------------- | +| `Component` | Class | Base component class | +| `app` | Function | Access IoC container | +| `state` | Function | Create reactive state | +| `ojs` | Function | Bootstrap application | +| `context` | Function | Access context | +| `putContext` | Function | Register context | +| `Mediator` | Class | Base mediator class | +| `payload` | Function | Create event payload | +| `Utils` | Object | Utility functions | + +### Component Lifecycle + +| Method | Description | +| --------------- | ----------------------------------- | +| `constructor()` | Initialize component | +| `mount()` | Component mounted (async supported) | +| `render()` | Generate component UI | +| `unmount()` | Component unmounted | + +### State API + +| Property/Method | Description | +| ---------------- | ---------------------------------- | +| `.value` | Get/set state value | +| `.listener(fn)` | Add state change listener | +| `.previousValue` | Previous state value (in listener) | + +--- + +## 🎯 Best Practices + +### ✅ Do's + +- Use contexts for global state +- Keep components small and focused +- Leverage lifecycle hooks appropriately +- Use mediators for business logic +- Use computed properties for derived state + +### ❌ Don'ts + +- Don't mutate state directly +- Don't mix business logic with UI +- Don't create functions in render +- Don't emit events in tight loops + +--- + +## 🐛 Troubleshooting + +**Component not re-rendering?** + +- Ensure state is updated via `.value =` +- Verify state is used in `render()` + +**Events not firing?** + +- Check events are registered +- Verify event names match exactly + +**Router not working?** + +- Call `router.listen()` after defining routes +- Check route paths are correct + +--- + +## 📦 Package Info + +- **Size**: ~95KB (ES), ~42KB (UMD) +- **Dependencies**: Zero runtime dependencies +- **Browser Support**: Modern browsers (ES6+) +- **License**: MIT + +--- + +## 📚 Learn More + +- [Full Documentation](https://github.com/yourusername/modular-openscriptjs) +- [Examples](https://github.com/yourusername/modular-openscriptjs/tree/main/examples) +- [API Reference](https://github.com/yourusername/modular-openscriptjs/wiki) - [Issue Tracker](https://github.com/yourusername/modular-openscriptjs/issues) -- [npm Package](https://www.npmjs.com/package/modular-openscriptjs) --- -Built with ❤️ using OpenScript +## 🤝 Contributing + +We welcome contributions! See our [Contributing Guide](https://github.com/yourusername/modular-openscriptjs/blob/main/CONTRIBUTING.md). + +--- + +## 📄 License + +MIT © Levi Kamara Zwannah + +--- + +**Built with ❤️ using OpenScript** + +[⭐ Star on GitHub](https://github.com/yourusername/modular-openscriptjs) | [📦 View on npm](https://www.npmjs.com/package/modular-openscriptjs) diff --git a/package.json b/package.json index 967f5bf..61bba34 100644 --- a/package.json +++ b/package.json @@ -61,20 +61,20 @@ "node": ">=16.0.0" }, "devDependencies": { - "@babel/core": "^7.23.5", - "@babel/generator": "^7.23.5", - "@babel/parser": "^7.23.5", - "@babel/traverse": "^7.23.5", - "@testing-library/dom": "^10.4.1", - "@vitest/ui": "^4.0.13", - "autoprefixer": "^10.4.16", + "@babel/core": "^7.26.0", + "@babel/generator": "^7.26.2", + "@babel/parser": "^7.26.2", + "@babel/traverse": "^7.26.2", + "@testing-library/dom": "^10.4.0", + "@vitest/ui": "^4.0.14", + "autoprefixer": "^10.4.20", "happy-dom": "^20.0.10", - "jsdom": "^27.2.0", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "terser": "^5.44.1", - "vite": "^5.0.7", - "vitest": "^4.0.13" + "jsdom": "^25.0.1", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "terser": "^5.36.0", + "vite": "^7.2.4", + "vitest": "^4.0.14" }, "peerDependencies": { "vite": "^4.0.0 || ^5.0.0" From eb39f4c14b2c0aee4b0ce5592fba40257d71a964 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 01:49:58 +0300 Subject: [PATCH 09/46] updated the packages --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 61bba34..f2ac635 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.0", + "version": "1.0.1", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", From 6c649753510694c3ebce0393bb1b95f8eeb75049 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 02:11:04 +0300 Subject: [PATCH 10/46] working on ojs events --- src/index.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/index.js b/src/index.js index f306db4..bf0b96e 100644 --- a/src/index.js +++ b/src/index.js @@ -44,6 +44,15 @@ container.value("loader", loader); container.value("autoload", autoload); container.value("h", h); +let ojsRouterEvents = { + ojs: { + beforeRouteChange: true, + routeChanged: true, + }, +}; + +broker.registerEvents(ojsRouterEvents); + // Global Helpers const state = State.state; const ojs = (...classDeclarations) => new Runner().run(...classDeclarations); From d0709abab2274486bd6068fcbaa29c84f51e8e4e Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 02:11:35 +0300 Subject: [PATCH 11/46] working on ojs events --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f2ac635..822a72f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.1", + "version": "1.0.2", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", From 0836c3c1578a7c15cd373a950794ddea8e3d5917 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 02:14:47 +0300 Subject: [PATCH 12/46] getting the initial build out --- package.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/package.json b/package.json index 822a72f..7c09d80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.2", + "version": "1.0.3", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", @@ -75,8 +75,5 @@ "terser": "^5.36.0", "vite": "^7.2.4", "vitest": "^4.0.14" - }, - "peerDependencies": { - "vite": "^4.0.0 || ^5.0.0" } } From cb07e3ef24a2d661dd707479bf936b7090a9f7fa Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 02:18:44 +0300 Subject: [PATCH 13/46] getting the initial build out --- package.json | 2 +- src/index.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c09d80..a03cdb6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.3", + "version": "1.0.4", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/index.js b/src/index.js index bf0b96e..76e4998 100644 --- a/src/index.js +++ b/src/index.js @@ -135,6 +135,7 @@ export { mediators, eData, payload, + ojsRouterEvents, }; // Default export object @@ -180,4 +181,5 @@ export default { mediators, eData, payload, + ojsRouterEvents, }; From 1a574be1e91b2c546a2864c8ff0ae44d6e08722b Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 02:38:13 +0300 Subject: [PATCH 14/46] gotten the first render successfully --- package.json | 2 +- src/component/Component.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a03cdb6..c6241a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.4", + "version": "1.0.5", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/component/Component.js b/src/component/Component.js index f94484d..eb8a15e 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -3,7 +3,8 @@ import DOMReconciler from "./DOMReconciler.js"; import BrokerRegistrar from "../broker/BrokerRegistrar.js"; import State from "../core/State.js"; import { container } from "../core/Container.js"; -import { h } from "./h.js"; + +const h = container.resolve("h"); /** * Base Component Class From 5673590d175c241f7b5d34449b4a1742c118199f Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 11:42:27 +0300 Subject: [PATCH 15/46] fixing auto imports --- build/vite-plugin-openscript.js | 426 ++++++++----------- docs/COMPONENT_AUTO_IMPORT.md | 184 ++++++++ docs/COMPONENT_AUTO_IMPORT_README_SECTION.md | 39 ++ examples/vite.config.example.js | 24 ++ package.json | 2 +- src/component/Component.js | 15 +- src/component/MarkupEngine.js | 3 +- src/component/h.js | 3 - src/core/AutoLoader.js | 8 +- src/index.js | 49 ++- src/router/Router.js | 2 +- templates/basic/src/components/App.js | 3 +- test/Component.test.js | 11 +- test/Context.test.js | 7 +- test/MarkupEngine.test.js | 11 +- test/RegistrationGuard.test.js | 6 +- test/RunnerSingleton.test.js | 17 +- vite.config.js | 2 +- 18 files changed, 537 insertions(+), 275 deletions(-) create mode 100644 docs/COMPONENT_AUTO_IMPORT.md create mode 100644 docs/COMPONENT_AUTO_IMPORT_README_SECTION.md create mode 100644 examples/vite.config.example.js delete mode 100644 src/component/h.js diff --git a/build/vite-plugin-openscript.js b/build/vite-plugin-openscript.js index 5862151..9b425bc 100644 --- a/build/vite-plugin-openscript.js +++ b/build/vite-plugin-openscript.js @@ -1,243 +1,191 @@ +import fs from "fs"; +import path from "path"; +import { normalizePath } from "vite"; + +/** + * OpenScript Component Auto-Import Plugin + * Automatically discovers components and provides IDE autocomplete + bundling + */ +export function openScriptComponentPlugin(options = {}) { + const { + componentsDir = "src/components", + autoRegister = true, + generateTypes = true, + } = options; + + let config; + let components = []; + const virtualModuleId = "virtual:openscript-components"; + const resolvedVirtualModuleId = "\0" + virtualModuleId; + + return { + name: "openscript-component-plugin", + + configResolved(resolvedConfig) { + config = resolvedConfig; + }, + + buildStart() { + // Scan components directory + const componentsPath = path.resolve(config.root, componentsDir); + + if (!fs.existsSync(componentsPath)) { + console.warn( + `[OpenScript] Components directory not found: ${componentsPath}` + ); + return; + } + + // Find all component files + components = scanComponents(componentsPath); + + console.log( + `[OpenScript] Found ${components.length} components:`, + components.map((c) => c.name).join(", ") + ); + + // Generate TypeScript definitions if enabled + if (generateTypes) { + generateTypeDefinitions(config.root, componentsDir, components); + } + }, + + resolveId(id) { + if (id === virtualModuleId) { + return resolvedVirtualModuleId; + } + }, + + load(id) { + if (id === resolvedVirtualModuleId) { + // Generate virtual module that imports all components + return generateVirtualModule(componentsDir, components, autoRegister); + } + }, + + // HMR support + handleHotUpdate({ file, server }) { + if (file.includes(componentsDir)) { + // Reload virtual module when components change + const module = server.moduleGraph.getModuleById( + resolvedVirtualModuleId + ); + if (module) { + server.moduleGraph.invalidateModule(module); + return [module]; + } + } + }, + }; +} + +/** + * Scan components directory recursively + */ +function scanComponents(dir, basePath = "") { + const components = []; + + if (!fs.existsSync(dir)) return components; + + const files = fs.readdirSync(dir, { withFileTypes: true }); + + for (const file of files) { + const fullPath = path.join(dir, file.name); + const relativePath = path.join(basePath, file.name); + + if (file.isDirectory()) { + // Recursively scan subdirectories + components.push(...scanComponents(fullPath, relativePath)); + } else if (file.name.endsWith(".js") && !file.name.endsWith(".test.js")) { + // Extract component name from filename + const name = path.basename(file.name, ".js"); + + // Skip non-component files (lowercase, index, etc.) + if (name[0] === name[0].toUpperCase() && name !== "index") { + components.push({ + name, + path: relativePath.replace(/\\/g, "/"), + }); + } + } + } + + return components; +} + +/** + * Generate TypeScript definition file for IDE autocomplete + */ +function generateTypeDefinitions(root, componentsDir, components) { + const dtsPath = path.resolve(root, "src/openscript-components.d.ts"); + + const imports = components + .map( + (c) => + `import type ${c.name} from './${componentsDir}/${c.path.replace( + ".js", + "" + )}';` + ) + .join("\n"); + + const properties = components + .map((c) => ` ${c.name}: typeof ${c.name};`) + .join("\n"); + + const content = `// Auto-generated by OpenScript - DO NOT EDIT +// This file provides IDE autocomplete for h.ComponentName + +import type { MarkupEngine } from 'modular-openscriptjs'; + +${imports} + +declare module 'modular-openscriptjs' { + interface MarkupEngine { +${properties} + } +} + +export {}; +`; + + fs.writeFileSync(dtsPath, content, "utf-8"); + console.log(`[OpenScript] Generated type definitions: ${dtsPath}`); +} + /** - * Vite Plugin: OpenScript Component Name Preserver - * - * This plugin transforms OpenScript component files before bundling to add - * explicit component names that survive minification. - * - * Problem: When Vite minifies code, class names change (e.g., TodoApp -> t), - * breaking OpenScript's component registration which relies on constructor.name - * - * Solution: Parse component files and inject the component name explicitly - * in the constructor, making it immune to minification. + * Generate virtual module content that imports and registers all components */ +function generateVirtualModule(componentsDir, components, autoRegister) { + const imports = components + .map((c) => `import ${c.name} from '../${componentsDir}/${c.path}';`) + .join("\n"); + + const exports = components.map((c) => c.name).join(", "); + + const registration = autoRegister + ? ` +// Auto-register all components +const components = { ${exports} }; + +export async function registerAllComponents() { + for (const [name, Component] of Object.entries(components)) { + const instance = new Component(); + await instance.mount(); + } +} +` + : ""; + + return `// Auto-generated by OpenScript Component Plugin +${imports} + +${registration} + +// Export all components for manual use +export { ${exports} }; -import { parse } from "@babel/parser"; -import traverse from "@babel/traverse"; -import generate from "@babel/generator"; - -export default function openScriptComponentPlugin() { - return { - name: "vite-plugin-openscript-components", - - // Only transform JS/TS files - transform(code, id) { - // Skip node_modules and non-component files - if (id.includes("node_modules")) { - return null; - } - - // Only process files that likely contain components - if ( - !code.includes("extends Component") && - !code.includes("extend Component") && - !code.includes("h.") && - !code.includes("h[") - ) { - return null; - } - - try { - // Parse the code into an AST - const ast = parse(code, { - sourceType: "module", - plugins: ["jsx", "typescript", "decorators-legacy"], - }); - - let modified = false; - - // Traverse the AST to find component classes - traverse.default(ast, { - ClassDeclaration(path) { - const node = path.node; - - // Check if this class extends Component - if (!node.superClass) return; - - const extendsComponent = - node.superClass.name === "Component" || - node.superClass.property?.name === "Component"; - - if (!extendsComponent) return; - - // Get the component name - const componentName = node.id.name; - - // Check if constructor exists - let hasConstructor = false; - let constructorPath = null; - - for (const member of node.body.body) { - if ( - member.type === "ClassMethod" && - member.kind === "constructor" - ) { - hasConstructor = true; - constructorPath = member; - break; - } - } - - if (hasConstructor && constructorPath) { - // Check if super() exists and add name assignment after it - const body = constructorPath.body.body; - let superIndex = -1; - - for (let i = 0; i < body.length; i++) { - const stmt = body[i]; - if ( - stmt.type === "ExpressionStatement" && - stmt.expression.type === "CallExpression" && - stmt.expression.callee.type === "Super" - ) { - superIndex = i; - break; - } - } - - // Check if name is already set - const hasNameAssignment = body.some( - (stmt) => - stmt.type === "ExpressionStatement" && - stmt.expression.type === - "AssignmentExpression" && - stmt.expression.left.property?.name === - "name" - ); - - if (superIndex !== -1 && !hasNameAssignment) { - // Insert `this.name = 'ComponentName';` after super() - body.splice(superIndex + 1, 0, { - type: "ExpressionStatement", - expression: { - type: "AssignmentExpression", - operator: "=", - left: { - type: "MemberExpression", - object: { type: "ThisExpression" }, - property: { - type: "Identifier", - name: "name", - }, - computed: false, - }, - right: { - type: "StringLiteral", - value: componentName, - }, - }, - }); - modified = true; - } - } else { - // No constructor exists, add one with super() and name assignment - node.body.body.unshift({ - type: "ClassMethod", - kind: "constructor", - key: { - type: "Identifier", - name: "constructor", - }, - params: [ - { - type: "RestElement", - argument: { - type: "Identifier", - name: "args", - }, - }, - ], - body: { - type: "BlockStatement", - body: [ - { - type: "ExpressionStatement", - expression: { - type: "CallExpression", - callee: { type: "Super" }, - arguments: [ - { - type: "SpreadElement", - argument: { - type: "Identifier", - name: "args", - }, - }, - ], - }, - }, - { - type: "ExpressionStatement", - expression: { - type: "AssignmentExpression", - operator: "=", - left: { - type: "MemberExpression", - object: { - type: "ThisExpression", - }, - property: { - type: "Identifier", - name: "name", - }, - computed: false, - }, - right: { - type: "StringLiteral", - value: componentName, - }, - }, - }, - ], - }, - }); - modified = true; - } - }, - - // Transform h.div(...) to h['div'](...) - // This prevents minification from mangling element/component names - MemberExpression(path) { - const node = path.node; - - // Only transform if: - // 1. Object is identifier 'h' - // 2. Property is accessed with dot notation (not already computed) - // 3. Not already a computed member expression - if ( - node.object.type === "Identifier" && - node.object.name === "h" && - !node.computed && - node.property.type === "Identifier" - ) { - // Convert to computed member expression: h['propertyName'] - node.computed = true; - node.property = { - type: "StringLiteral", - value: node.property.name, - }; - modified = true; - } - }, - }); - - // If we modified the AST, generate new code - if (modified) { - const output = generate.default(ast, {}, code); - return { - code: output.code, - map: output.map, - }; - } - - return null; - } catch (error) { - // If parsing fails, log and return original code - console.warn( - `Failed to parse ${id} for OpenScript component transformation:`, - error.message - ); - return null; - } - }, - }; +// Export component registry +export default { ${exports} }; +`; } diff --git a/docs/COMPONENT_AUTO_IMPORT.md b/docs/COMPONENT_AUTO_IMPORT.md new file mode 100644 index 0000000..19af186 --- /dev/null +++ b/docs/COMPONENT_AUTO_IMPORT.md @@ -0,0 +1,184 @@ +# OpenScript Component Auto-Import System + +## How to Use (For Developers) + +### 1. Install OpenScript + +```bash +npm install modular-openscriptjs +``` + +### 2. Configure Vite + +```javascript +// vite.config.js +import { defineConfig } from "vite"; +import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; + +export default defineConfig({ + plugins: [ + openScriptComponentPlugin({ + componentsDir: "src/components", // default + autoRegister: true, // auto-register components + generateTypes: true, // generate .d.ts for IDE + }), + ], +}); +``` + +### 3. Create Components + +```javascript +// src/components/TodoList.js +import { Component, app, state } from "modular-openscriptjs"; + +const h = app("h"); + +export default class TodoList extends Component { + constructor() { + super(); + this.todos = state([]); + } + + render() { + return h.div( + h.h2("My Todos"), + h.ul(...this.todos.value.map((todo) => h.li(todo.text))) + ); + } +} +``` + +### 4. Use Components with Auto-Import + +```javascript +// src/main.js +import { app } from "modular-openscriptjs"; +import "virtual:openscript-components"; // Auto-imports all components + +const h = app("h"); + +// IDE will autocomplete ComponentName! +// Component is automatically imported and bundled by Vite! +h.TodoList({ parent: document.body }); +``` + +## Features + +✅ **IDE Autocomplete**: Type `h.` and see all your components +✅ **Auto-Import**: No need to manually import components +✅ **TypeScript Support**: Generated `.d.ts` files +✅ **HMR Support**: Hot module replacement during development +✅ **Automatic Bundling**: Vite includes all components in bundle +✅ **Nested Components**: Supports subdirectories in components/ + +## How It Works + +1. **Component Discovery**: Plugin scans `src/components/` for all Component files +2. **Type Generation**: Creates `openscript-components.d.ts` with type definitions +3. **Virtual Module**: Creates a virtual module that imports all components +4. **Auto-Registration**: Optionally auto-registers all components on app start +5. **IDE Support**: TypeScript definitions provide autocomplete and type checking + +## Advanced Usage + +### Manual Component Registration + +```javascript +// vite.config.js +openScriptComponentPlugin({ + autoRegister: false, // Disable auto-registration +}); +``` + +```javascript +// src/main.js +import components, { + registerAllComponents, +} from "virtual:openscript-components"; + +// Manually register when needed +await registerAllComponents(); + +// Or register individually +const todoList = new components.TodoList(); +await todoList.mount(); +``` + +### Custom Components Directory + +```javascript +openScriptComponentPlugin({ + componentsDir: "src/ui/components", +}); +``` + +### Exclude Components from Auto-Discovery + +Name files with lowercase or prefix with underscore: + +- `utils.js` ❌ (lowercase, won't be discovered) +- `_BaseComponent.js` ❌ (underscore prefix, won't be discovered) +- `TodoList.js` ✅ (PascalCase, will be discovered) + +## TypeScript Example + +```typescript +// src/main.ts +import { app } from "modular-openscriptjs"; +import "virtual:openscript-components"; + +const h = app("h"); + +// Full type safety! +h.TodoList({ + parent: document.body, + resetParent: true, +}); +``` + +## Comparison with JSX + +**JSX/React:** + +```jsx +import TodoList from "./components/TodoList"; +; +``` + +**OpenScript (with plugin):** + +```javascript +import "virtual:openscript-components"; +h.TodoList(); +``` + +Both provide: + +- ✅ IDE autocomplete +- ✅ Type checking +- ✅ Proper bundling +- ✅ HMR support + +## Migration from Manual Imports + +**Before:** + +```javascript +import TodoList from "./components/TodoList"; +import Header from "./components/Header"; + +const todoList = new TodoList(); +await todoList.mount(); +h.TodoList({ parent: document.body }); +``` + +**After:** + +```javascript +import "virtual:openscript-components"; + +// Components auto-registered and available on h! +h.TodoList({ parent: document.body }); +h.Header({ parent: document.body }); +``` diff --git a/docs/COMPONENT_AUTO_IMPORT_README_SECTION.md b/docs/COMPONENT_AUTO_IMPORT_README_SECTION.md new file mode 100644 index 0000000..2bc89a0 --- /dev/null +++ b/docs/COMPONENT_AUTO_IMPORT_README_SECTION.md @@ -0,0 +1,39 @@ +## Component Auto-Import Feature + +OpenScript provides automatic component discovery and import, similar to JSX, giving you IDE autocomplete and ensuring all components are properly bundled. + +### Setup + +```javascript +// vite.config.js +import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; + +export default { + plugins: [openScriptComponentPlugin()], +}; +``` + +### Usage + +```javascript +// src/main.js +import { app } from "modular-openscriptjs"; +import "virtual:openscript-components"; // Auto-imports all components! + +const h = app("h"); + +// IDE will autocomplete component names! +// Components are automatically imported and bundled! +h.TodoList({ parent: document.body }); +h.Header({ parent: document.body }); +``` + +**Benefits:** + +- ✅ IDE autocomplete for `h.ComponentName` +- ✅ Automatic component imports (no manual imports needed) +- ✅ TypeScript support with generated `.d.ts` files +- ✅ Proper Vite bundling +- ✅ Hot Module Replacement (HMR) + +See [Component Auto-Import Guide](./docs/COMPONENT_AUTO_IMPORT.md) for details. diff --git a/examples/vite.config.example.js b/examples/vite.config.example.js new file mode 100644 index 0000000..8d60605 --- /dev/null +++ b/examples/vite.config.example.js @@ -0,0 +1,24 @@ +// Example vite.config.js for OpenScript projects +import { defineConfig } from "vite"; +import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; + +export default defineConfig({ + plugins: [ + openScriptComponentPlugin({ + // Directory where your components are located + componentsDir: "src/components", + + // Auto-register all components on app start + // Set to false if you want manual control + autoRegister: true, + + // Generate TypeScript definitions for IDE autocomplete + // Creates src/openscript-components.d.ts + generateTypes: true, + }), + ], + + build: { + target: "es2015", + }, +}); diff --git a/package.json b/package.json index c6241a9..74cc177 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.5", + "version": "1.0.5.1", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/component/Component.js b/src/component/Component.js index eb8a15e..41a68fb 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -4,8 +4,6 @@ import BrokerRegistrar from "../broker/BrokerRegistrar.js"; import State from "../core/State.js"; import { container } from "../core/Container.js"; -const h = container.resolve("h"); - /** * Base Component Class */ @@ -113,6 +111,7 @@ export default class Component { routeChanged: () => { setTimeout(() => { if (this.markup().length == 0) { + const h = container.resolve("h"); if (this.isAnonymous) { return h.deleteComponent(this.name); } @@ -146,6 +145,7 @@ export default class Component { if (!Array.isArray(args)) { args = [args]; } + const h = container.resolve("h"); return h.func([this, name], ...args); } @@ -167,6 +167,7 @@ export default class Component { ); } + const h = container.resolve("h"); return h.getComponent(splitted[0]).method(splitted[1], args); } @@ -255,6 +256,7 @@ export default class Component { getDeclaredListeners() { let obj = this; let seen = new Set(); + const h = container.resolve("h"); do { if (!(obj instanceof Component)) break; @@ -343,7 +345,7 @@ export default class Component { ); return; } - + const h = container.resolve("h"); h.component(this.name, this); this.claimListeners(); @@ -376,6 +378,7 @@ export default class Component { * visible */ checkVisibility() { + const h = container.resolve("h"); let elem = h.dom.querySelector(`ojs-${this.kebab(this.name)}`); if (elem && elem.parentElement?.style.display !== "none" && !this.visible) { @@ -422,7 +425,7 @@ export default class Component { */ async bindComponent() { this.emit(this.EVENTS.prebind); - + const h = container.resolve("h"); let all = h.dom.querySelectorAll(`ojs-${this.kebab(this.name)}-tmp--`); if (all.length == 0 && !this.bindCalled) { @@ -469,6 +472,7 @@ export default class Component { * @returns */ markup(parent = null) { + const h = container.resolve("h"); if (!parent) parent = h.dom; return parent.querySelectorAll(`ojs-${this.kebab(this.name)}`); @@ -550,6 +554,7 @@ export default class Component { * Gets all the listeners for itself and adds them to itself */ claimListeners() { + const h = container.resolve("h"); if (!h.eventsMap.has(this.name)) return; let events = h.eventsMap.get(this.name); @@ -596,6 +601,7 @@ export default class Component { * @returns {DocumentFragment|HTMLElement|String|Array} */ render(...args) { + const h = container.resolve("h"); return h.ojs(...args); } @@ -671,6 +677,7 @@ export default class Component { * @returns */ wrap(...args) { + const h = container.resolve("h"); const lastArg = args[args.length - 1]; let { index, parent, resetParent, states, replaceParent, firstOfParent } = this.getParentAndListen(args); diff --git a/src/component/MarkupEngine.js b/src/component/MarkupEngine.js index 91b9350..a1d002f 100644 --- a/src/component/MarkupEngine.js +++ b/src/component/MarkupEngine.js @@ -1,8 +1,8 @@ import DOMReconciler from "./DOMReconciler.js"; import Utils from "../utils/Utils.js"; -import { h } from "./h.js"; // Circular dependency? h is used in MarkupEngine import Component from "./Component.js"; import State from "../core/State.js"; +import { container } from "../core/Container.js"; /** * Base Markup Engine Class @@ -494,6 +494,7 @@ export default class MarkupEngine { sc.forEach((c) => { if (!isComponentName(c.tagName.toLowerCase())) return; let cmpName = getComponentName(c.tagName); + const h = container.resolve("h"); h.getComponent(cmpName)?.emit(event, eventParams); }); } diff --git a/src/component/h.js b/src/component/h.js deleted file mode 100644 index 68f0738..0000000 --- a/src/component/h.js +++ /dev/null @@ -1,3 +0,0 @@ -import MarkupHandler from "./MarkupHandler.js"; - -export const h = MarkupHandler.proxy(); diff --git a/src/core/AutoLoader.js b/src/core/AutoLoader.js index be0f07b..b406a04 100644 --- a/src/core/AutoLoader.js +++ b/src/core/AutoLoader.js @@ -2,10 +2,6 @@ import Component from "../component/Component.js"; import { namespace } from "../utils/helpers.js"; import { container } from "./Container.js"; import MarkupEngine from "../component/MarkupEngine.js"; -import { h } from "../component/h.js"; -/** - * @type MarkupEngine - */ /** * AutoLoads a class from a file @@ -349,6 +345,10 @@ export default class AutoLoader { */ async setFile(names, content) { namespace(names[0]); + /** + * @type MarkupEngine + */ + const h = container.resolve("h"); let obj = window; let final = names.slice(0, names.length - 1); diff --git a/src/index.js b/src/index.js index 76e4998..d1b7b75 100644 --- a/src/index.js +++ b/src/index.js @@ -21,7 +21,6 @@ import Component from "./component/Component.js"; import DOMReconciler from "./component/DOMReconciler.js"; import MarkupEngine from "./component/MarkupEngine.js"; import MarkupHandler from "./component/MarkupHandler.js"; -import { h } from "./component/h.js"; import Utils from "./utils/Utils.js"; import DOM from "./utils/DOM.js"; @@ -34,6 +33,7 @@ const contextProvider = new ContextProvider(); const mediatorManager = new MediatorManager(); const loader = new AutoLoader(); const autoload = new AutoLoader(); +const h = MarkupHandler.proxy(); // Register global instances in container container.value("broker", broker); @@ -82,9 +82,50 @@ const coalesce = Utils.coalesce; const dom = DOM; /** - * Resolves an instance from the container or returns the container if no instance is provided - * @param {string} instance - * @returns {Container|Object} + * Access services from the IoC container + * @overload + * @param {'h'} instance - Get the MarkupEngine instance + * @returns {MarkupEngine} + */ +/** + * @overload + * @param {'router'} instance - Get the Router instance + * @returns {Router} + */ +/** + * @overload + * @param {'broker'} instance - Get the Broker instance + * @returns {Broker} + */ +/** + * @overload + * @param {'contextProvider'} instance - Get the ContextProvider instance + * @returns {ContextProvider} + */ +/** + * @overload + * @param {'mediatorManager'} instance - Get the MediatorManager instance + * @returns {MediatorManager} + */ +/** + * @overload + * @param {'loader'} instance - Get the AutoLoader instance + * @returns {AutoLoader} + */ +/** + * @overload + * @param {undefined} instance - Get the Container itself + * @returns {Container} + */ +/** + * @overload + * @param {string} instance - Get any registered service by name + * @returns {any} + */ +/** + * Access a service from the IoC container or get the container itself + * @param {string|undefined} [instance] - Service name or undefined to get container + * @returns {any} */ const app = (instance = null) => { if (instance === null) return container; diff --git a/src/router/Router.js b/src/router/Router.js index ddef805..7ac4261 100644 --- a/src/router/Router.js +++ b/src/router/Router.js @@ -1,4 +1,3 @@ -import { h } from "../component/h.js"; // Assuming h is here import { container } from "../core/Container.js"; import State from "../core/State.js"; // Assuming State is in core @@ -337,6 +336,7 @@ export default class Router { */ to(path, qs = {}) { if (this.isQualifiedUrl(path)) { + const h = container.resolve("h"); let link = h.a({ href: path, style: "display: none;", diff --git a/templates/basic/src/components/App.js b/templates/basic/src/components/App.js index e5a4b32..3117232 100644 --- a/templates/basic/src/components/App.js +++ b/templates/basic/src/components/App.js @@ -2,7 +2,8 @@ * Root Application Component */ -import { Component, app, ojs } from "modular-openscriptjs"; +import { Component, app, ojs } from "modular-openscriptjs" + import Counter from "./Counter.js"; const h = app("h"); diff --git a/test/Component.test.js b/test/Component.test.js index b7a7025..9e9df64 100644 --- a/test/Component.test.js +++ b/test/Component.test.js @@ -1,7 +1,15 @@ import { describe, it, expect, beforeEach } from "vitest"; import Component from "../src/component/Component.js"; -import { h } from "../src/component/h.js"; import State from "../src/core/State.js"; +import { app } from "../src/index.js"; + +// Get h from container, not at module level +let h; + +beforeEach(() => { + // Initialize h from container + h = app("h"); +}); describe("Component", () => { describe("Component Creation", () => { @@ -135,7 +143,6 @@ describe("Component", () => { } } - const component = new MountEventComponent(); await component.mount(); h.MountEventComponent(); diff --git a/test/Context.test.js b/test/Context.test.js index 8d309ea..21fe85b 100644 --- a/test/Context.test.js +++ b/test/Context.test.js @@ -1,10 +1,13 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import Context from "../src/core/Context.js"; -import { putContext, context, container } from "../src/index.js"; +import { putContext, context, container, app } from "../src/index.js"; import State from "../src/core/State.js"; describe("Context", () => { beforeEach(() => { + // Ensure container is initialized + app("h"); + // Clear context map to ensure fresh state for each test const contextProvider = container.resolve("contextProvider"); if (contextProvider && contextProvider.map) { diff --git a/test/MarkupEngine.test.js b/test/MarkupEngine.test.js index df7b40e..d839a58 100644 --- a/test/MarkupEngine.test.js +++ b/test/MarkupEngine.test.js @@ -1,7 +1,15 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { h } from "../src/component/h.js"; +import { app, MarkupHandler } from "../src/index.js"; + +// Get h inside each test, not at module level +let h; describe("Markup Engine (h)", () => { + beforeEach(() => { + // Initialize h from container + h = MarkupHandler.proxy(); + }); + describe("Basic Element Creation", () => { it("should create div element", () => { const element = h.div("Hello"); @@ -108,7 +116,6 @@ describe("Markup Engine (h)", () => { it("should create fragment with h.$()", () => { const fragment = h.$(h.div("First"), h.div("Second")); - expect(fragment.tagName).toBe("OJS-SPECIAL-FRAGMENT"); expect(fragment.childNodes.length).toBe(2); }); diff --git a/test/RegistrationGuard.test.js b/test/RegistrationGuard.test.js index d5a7857..f6cf1f2 100644 --- a/test/RegistrationGuard.test.js +++ b/test/RegistrationGuard.test.js @@ -1,9 +1,13 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import Component from "../src/component/Component.js"; import Mediator from "../src/mediator/Mediator.js"; import Listener from "../src/broker/Listener.js"; import { app } from "../src/index.js"; +beforeEach(() => { + app("h"); +}); + console.log("Running RegistrationGuard.test.js"); describe("Registration Guards", () => { diff --git a/test/RunnerSingleton.test.js b/test/RunnerSingleton.test.js index 6e9035c..5dd9834 100644 --- a/test/RunnerSingleton.test.js +++ b/test/RunnerSingleton.test.js @@ -1,9 +1,8 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import Runner from "../src/core/Runner.js"; import Component from "../src/component/Component.js"; import Mediator from "../src/mediator/Mediator.js"; import Listener from "../src/broker/Listener.js"; -import { container } from "../src/core/Container.js"; import { app } from "../src/index.js"; describe("Runner with IoC Container Singletons", () => { @@ -23,7 +22,7 @@ describe("Runner with IoC Container Singletons", () => { await runner.run(TestComponent); const classKey = runner.getClassKey(TestComponent); - const instance = container.resolve(classKey); + const instance = app(classKey); expect(instance).toBeDefined(); expect(instance).toBeInstanceOf(TestComponent); @@ -39,11 +38,11 @@ describe("Runner with IoC Container Singletons", () => { // First run await runner.run(TestComponent); const classKey = runner.getClassKey(TestComponent); - const firstInstance = container.resolve(classKey); + const firstInstance = app(classKey); // Second run - should retrieve same instance from container await runner.run(TestComponent); - const secondInstance = container.resolve(classKey); + const secondInstance = app(classKey); expect(secondInstance).toBe(firstInstance); }); @@ -57,7 +56,7 @@ describe("Runner with IoC Container Singletons", () => { const classKey = runner.getClassKey(functionalComponent); // Functional components are not singletons - expect(container.has(classKey)).toBe(false); + expect(app().has(classKey)).toBe(false); }); }); @@ -67,7 +66,7 @@ describe("Runner with IoC Container Singletons", () => { await runner.run(TestMediator); const classKey = runner.getClassKey(TestMediator); - const instance = container.resolve(classKey); + const instance = app(classKey); expect(instance).toBeDefined(); expect(instance).toBeInstanceOf(TestMediator); @@ -82,7 +81,7 @@ describe("Runner with IoC Container Singletons", () => { await runner.run(TestListener); const classKey = runner.getClassKey(TestListener); - const instance = container.resolve(classKey); + const instance = app(classKey); expect(instance).toBeDefined(); expect(instance).toBeInstanceOf(TestListener); @@ -100,7 +99,7 @@ describe("Runner with IoC Container Singletons", () => { // First run - registers the component await runner.run(TestComponent); const classKey = runner.getClassKey(TestComponent); - const instance = container.resolve(classKey); + const instance = app(classKey); expect(instance.__ojsRegistered).toBe(true); diff --git a/vite.config.js b/vite.config.js index cafebd6..381ac62 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,6 @@ import { defineConfig } from "vite"; import { resolve } from "path"; -import openScriptComponentPlugin from "./build/vite-plugin-openscript.js"; +import { openScriptComponentPlugin } from "./build/vite-plugin-openscript.js"; export default defineConfig({ plugins: [openScriptComponentPlugin()], From 864c34599934926344e9760d15dbcc4946f0714c Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 11:43:33 +0300 Subject: [PATCH 16/46] fixing auto imports --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 74cc177..21c50ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.5.1", + "version": "1.0.6", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", From 2073e8cc3c797f9539854a95ed189ff32be7dc12 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 12:06:27 +0300 Subject: [PATCH 17/46] fixed autoimports --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 21c50ca..1111f68 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "modular-openscriptjs", - "version": "1.0.6", + "version": "1.0.7", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", "module": "./dist/modular-openscriptjs.es.js", + "types": "./index.d.ts", "exports": { ".": { "import": "./dist/modular-openscriptjs.es.js", @@ -22,6 +23,7 @@ "templates", "build/vite-plugin-openscript.js", "styles", + "index.d.ts", "README.md", "LICENSE" ], From 52ecd5b962dafb9fa43052305d7379ff071bcfd2 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 12:15:30 +0300 Subject: [PATCH 18/46] fixing declarations --- build/vite-plugin-openscript.d.ts | 30 +++ index.d.ts | 330 ++++++++++++++++++++++++++++++ package.json | 10 +- 3 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 build/vite-plugin-openscript.d.ts create mode 100644 index.d.ts diff --git a/build/vite-plugin-openscript.d.ts b/build/vite-plugin-openscript.d.ts new file mode 100644 index 0000000..ca2b9cc --- /dev/null +++ b/build/vite-plugin-openscript.d.ts @@ -0,0 +1,30 @@ +// Type definitions for vite-plugin-openscript +// Project: https://github.com/OpenScriptJs/modular-openscript + +export interface OpenScriptComponentPluginOptions { + /** + * Directory where components are located + * @default 'src/components' + */ + componentsDir?: string; + + /** + * Automatically register all components on app start + * @default true + */ + autoRegister?: boolean; + + /** + * Generate TypeScript definitions for IDE autocomplete + * @default true + */ + generateTypes?: boolean; +} + +/** + * OpenScript Component Auto-Import Plugin + * Automatically discovers components and provides IDE autocomplete + bundling + */ +export function openScriptComponentPlugin( + options?: OpenScriptComponentPluginOptions +): any; diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..2cddd18 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,330 @@ +// Type definitions for modular-openscriptjs +// Project: https://github.com/OpenScriptJs/modular-openscript +// Definitions by: OpenScript Team + +/** + * State Management + */ +export interface State { + value: T; + previousValue: T; + $__id__: string; + $__name__: string; + listener(callback: (state: State) => void): void; + listener(component: Component): void; + off(componentName: string): void; +} + +export function state(initialValue: T): State; + +/** + * Component System + */ +export class Component { + static aCId: number; + static uid: number; + static FRAGMENT: string; + + name: string; + mounted: boolean; + bound: boolean; + rendered: boolean; + rerendered: boolean; + visible: boolean; + states: { [key: string]: State }; + emitter: Emitter; + EVENTS: { + rendered: string; + rerendered: string; + premount: string; + mounted: string; + prebind: string; + bound: string; + markupBound: string; + beforeHidden: string; + hidden: string; + unmounted: string; + beforeVisible: string; + visible: string; + }; + + constructor(name?: string); + + mount(): Promise; + unmount(): void; + render(...args: any[]): HTMLElement | DocumentFragment | string | Array; + emit(event: string, args?: any[]): void; + on(event: string, ...listeners: Function[]): void; + onAll(event: string, ...listeners: Function[]): void; + hide(): boolean; + show(): boolean; + cleanUp(): void; +} + +/** + * Markup Engine (h) + */ +export interface MarkupEngine { + dom: Document; + + // HTML Elements + div(...args: any[]): HTMLDivElement; + span(...args: any[]): HTMLSpanElement; + p(...args: any[]): HTMLParagraphElement; + h1(...args: any[]): HTMLHeadingElement; + h2(...args: any[]): HTMLHeadingElement; + h3(...args: any[]): HTMLHeadingElement; + h4(...args: any[]): HTMLHeadingElement; + h5(...args: any[]): HTMLHeadingElement; + h6(...args: any[]): HTMLHeadingElement; + ul(...args: any[]): HTMLUListElement; + ol(...args: any[]): HTMLOListElement; + li(...args: any[]): HTMLLIElement; + button(...args: any[]): HTMLButtonElement; + input(...args: any[]): HTMLInputElement; + textarea(...args: any[]): HTMLTextAreaElement; + select(...args: any[]): HTMLSelectElement; + option(...args: any[]): HTMLOptionElement; + form(...args: any[]): HTMLFormElement; + label(...args: any[]): HTMLLabelElement; + a(...args: any[]): HTMLAnchorElement; + img(...args: any[]): HTMLImageElement; + table(...args: any[]): HTMLTableElement; + thead(...args: any[]): HTMLTableSectionElement; + tbody(...args: any[]): HTMLTableSectionElement; + tfoot(...args: any[]): HTMLTableSectionElement; + tr(...args: any[]): HTMLTableRowElement; + th(...args: any[]): HTMLTableCellElement; + td(...args: any[]): HTMLTableCellElement; + header(...args: any[]): HTMLElement; + footer(...args: any[]): HTMLElement; + section(...args: any[]): HTMLElement; + article(...args: any[]): HTMLElement; + nav(...args: any[]): HTMLElement; + aside(...args: any[]): HTMLElement; + main(...args: any[]): HTMLElement; + + // Fragment creators + $(...args: any[]): DocumentFragment; + _(...args: any[]): DocumentFragment; + + // Component helpers + getComponent(name: string): Component; + component(name: string, instance: Component): void; + + // Generic element creator + [key: string]: any; +} + +/** + * Router + */ +export class Router { + params: { [key: string]: string }; + basePath(path: string): this; + on(path: string, handler: () => void, name?: string): this; + prefix(prefix: string): this; + group(callback: () => void): this; + default(handler: () => void): this; + to(name: string, params?: any): void; + push(path: string): void; + back(): void; + listen(): void; +} + +/** + * Event System + */ +export interface EventData { + meta(data: any): this; + message(data: any): this; + encode(): string; +} + +export function payload(message?: any, meta?: any): string; +export function eData(meta?: any, message?: any): string; + +export class Broker { + registerEvents(events: any): void; + send(eventName: string, data?: string): void; + on(eventName: string, callback: (data: any, eventName: string) => void): void; +} + +export class Listener { + __ojsRegistered?: boolean; + register(): Promise; +} + +export class Mediator { + __ojsRegistered?: boolean; + register(): Promise; +} + +/** + * Context System + */ +export class Context { + states(stateObj: { [key: string]: any }): void; + [key: string]: any; +} + +export class ContextProvider { + map: Map; + context(name: string): Context; + load(referenceName: string | string[], qualifiedName: string): void; +} + +export function context(name: string): Context; +export function putContext(referenceName: string | string[], qualifiedName: string): void; + +/** + * IoC Container + */ +export class Container { + singleton(name: string, implementation: any, dependencies?: string[]): this; + transient(name: string, implementation: any, dependencies?: string[]): this; + factory(name: string, factory: (container: Container) => any): this; + value(name: string, value: any): this; + resolve(name: string): T; + has(name: string): boolean; + getServiceNames(): string[]; + clear(): void; +} + +export const container: Container; + +/** + * App Helper with Overloads for Type Safety + */ +export function app(instance: 'h'): MarkupEngine; +export function app(instance: 'router'): Router; +export function app(instance: 'broker'): Broker; +export function app(instance: 'contextProvider'): ContextProvider; +export function app(instance: 'mediatorManager'): MediatorManager; +export function app(instance: 'loader'): AutoLoader; +export function app(instance: 'autoload'): AutoLoader; +export function app(): Container; +export function app(instance: string): any; + +/** + * Additional Classes + */ +export class Emitter { + on(event: string, callback: Function): void; + once(event: string, callback: Function): void; + emit(event: string, ...args: any[]): void; +} + +export class MediatorManager { + fetchMediators(qualifiedName: string): void; +} + +export class AutoLoader { + req(qualifiedName: string): Promise; + include(qualifiedName: string): Promise; +} + +export class Runner { + run(...classDeclarations: any[]): Promise; + getClassKey(classDeclaration: any): string; +} + +export class ProxyFactory { + static create(target: any, handler: any): any; +} + +export class DOMReconciler { + reconcile(newNode: Node, oldNode: Node): void; +} + +export class MarkupHandler { + static proxy(): MarkupEngine; +} + +/** + * Utilities + */ +export namespace Utils { + function lazyFor(iterable: T[], callback: (item: T, index: number) => any): any[]; + function each(iterable: T[], callback: (item: T, index: number) => void): void; + function ifElse(condition: boolean, trueValue: any, falseValue: any): any; + function coalesce(...values: any[]): any; + function parsePayload(encodedData: string): { message: any; meta: any }; +} + +export namespace DOM { + function querySelector(selector: string): HTMLElement | null; + function querySelectorAll(selector: string): NodeListOf; + function createElement(tag: string): HTMLElement; +} + +/** + * Helper Functions + */ +export function ojs(...classDeclarations: any[]): Promise; +export function req(qualifiedName: string): Promise; +export function include(qualifiedName: string): Promise; +export function v(state: State, callback?: (state: State) => any, ...args: any[]): any; +export function component(name: string): Component; +export function mediators(names: string[]): void; +export function isClass(func: any): boolean; +export function namespace(path: string): any; + +/** + * Vite Plugin + */ +export interface OpenScriptComponentPluginOptions { + componentsDir?: string; + autoRegister?: boolean; + generateTypes?: boolean; +} + +export function openScriptComponentPlugin(options?: OpenScriptComponentPluginOptions): any; + +/** + * Default Export + */ +declare const _default: { + Runner: typeof Runner; + Emitter: typeof Emitter; + EventData: typeof EventData; + State: typeof State; + ContextProvider: typeof ContextProvider; + Context: typeof Context; + ProxyFactory: typeof ProxyFactory; + AutoLoader: typeof AutoLoader; + Router: typeof Router; + Broker: typeof Broker; + Listener: typeof Listener; + Mediator: typeof Mediator; + MediatorManager: typeof MediatorManager; + Component: typeof Component; + DOMReconciler: typeof DOMReconciler; + MarkupEngine: typeof MarkupEngine; + MarkupHandler: typeof MarkupHandler; + Utils: typeof Utils; + DOM: typeof DOM; + app: typeof app; + isClass: typeof isClass; + namespace: typeof namespace; + Container: typeof Container; + container: typeof container; + state: typeof state; + ojs: typeof ojs; + req: typeof req; + include: typeof include; + v: typeof v; + context: typeof context; + putContext: typeof putContext; + each: typeof Utils.each; + ifElse: typeof Utils.ifElse; + coalesce: typeof Utils.coalesce; + dom: typeof DOM; + component: typeof component; + mediators: typeof mediators; + eData: typeof eData; + payload: typeof payload; + ojsRouterEvents: any; +}; + +export default _default; diff --git a/package.json b/package.json index 1111f68..967606b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.7", + "version": "1.0.8", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", @@ -8,11 +8,16 @@ "types": "./index.d.ts", "exports": { ".": { + "types": "./index.d.ts", "import": "./dist/modular-openscriptjs.es.js", "require": "./dist/modular-openscriptjs.umd.js" }, "./styles": "./dist/styles/tailwind.css", - "./plugin": "./build/vite-plugin-openscript.js" + "./plugin": { + "types": "./build/vite-plugin-openscript.d.ts", + "import": "./build/vite-plugin-openscript.js", + "require": "./build/vite-plugin-openscript.js" + } }, "bin": { "create-ojs-app": "bin/create-ojs-app" @@ -22,6 +27,7 @@ "bin", "templates", "build/vite-plugin-openscript.js", + "build/vite-plugin-openscript.d.ts", "styles", "index.d.ts", "README.md", From c2c03dfd2c8d3622381b674034fcc44c50d48002 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 18:01:49 +0300 Subject: [PATCH 19/46] fixing declarations --- build/vite-plugin-openscript.js | 39 +++++++++++++++++++++ package.json | 2 +- templates/bootstrap/vite.config.js | 10 +++++- test_regex.js | 55 ++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 test_regex.js diff --git a/build/vite-plugin-openscript.js b/build/vite-plugin-openscript.js index 9b425bc..d9084db 100644 --- a/build/vite-plugin-openscript.js +++ b/build/vite-plugin-openscript.js @@ -63,6 +63,45 @@ export function openScriptComponentPlugin(options = {}) { } }, + transform(code, id) { + // Only transform files in components directory + if (!id.includes(componentsDir) || !id.endsWith(".js")) return; + + // Find class definition + const classMatch = code.match(/class\s+(\w+)\s+extends\s+Component/); + if (!classMatch) return; + + const className = classMatch[1]; + + // If code already sets this.name explicitly, skip (simple check) + // We still use the runtime check (!this.name) to be safe, but this avoids double injection if we run multiple times + if (code.includes(`this.name = "${className}"`)) return; + + if (code.includes("constructor")) { + // Inject after super() + // Matches super(...) or super() with optional semicolon + return code.replace( + /(super\s*\([^)]*\)\s*;?)/, + `$1\n if (!this.name) this.name = "${className}";` + ); + } else { + // No constructor, inject one + // Find the first opening brace after class definition + const classDef = classMatch[0]; + const openBraceIndex = code.indexOf("{", code.indexOf(classDef)); + + if (openBraceIndex !== -1) { + return ( + code.slice(0, openBraceIndex + 1) + + `\n constructor() { super(); this.name = "${className}"; }` + + code.slice(openBraceIndex + 1) + ); + } + } + + return code; + }, + // HMR support handleHotUpdate({ file, server }) { if (file.includes(componentsDir)) { diff --git a/package.json b/package.json index 967606b..605b636 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.8", + "version": "1.0.9", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/templates/bootstrap/vite.config.js b/templates/bootstrap/vite.config.js index 00d18e8..e90ad5e 100644 --- a/templates/bootstrap/vite.config.js +++ b/templates/bootstrap/vite.config.js @@ -1,4 +1,5 @@ import { defineConfig } from 'vite'; +import { openScriptComponentPlugin } from '../..'; export default defineConfig({ server: { @@ -8,5 +9,12 @@ export default defineConfig({ build: { outDir: 'dist', sourcemap: true - } + }, + plugins: [ + openScriptComponentPlugin({ + componentsDir: 'src/components', + autoRegister: true, + generateTypes: true + }) + ] }); diff --git a/test_regex.js b/test_regex.js new file mode 100644 index 0000000..3a22d3d --- /dev/null +++ b/test_regex.js @@ -0,0 +1,55 @@ +const code1 = ` +export default class MyComponent extends Component { + constructor() { + super(); + this.state = {}; + } +} +`; + +const code2 = ` +export default class NoConstructor extends Component { + render() { return h.div(); } +} +`; + +const code3 = ` +export default class ExistingName extends Component { + constructor() { + super(); + this.name = "CustomName"; + } +} +`; + +function transform(code, className) { + if (code.includes(`this.name = "${className}"`)) return code; + + if (code.includes("constructor")) { + return code.replace( + /(super\s*\([^)]*\)\s*;?)/, + `$1\n if (!this.name) this.name = "${className}";` + ); + } else { + const classMatch = code.match(/class\s+(\w+)\s+extends\s+Component/); + const classDef = classMatch[0]; + const openBraceIndex = code.indexOf("{", code.indexOf(classDef)); + if (openBraceIndex !== -1) { + return ( + code.slice(0, openBraceIndex + 1) + + `\n constructor() { super(); this.name = "${className}"; }` + + code.slice(openBraceIndex + 1) + ); + } + } + return code; +} + +console.log("--- Test 1 ---"); +console.log(transform(code1, "MyComponent")); + +console.log("--- Test 2 ---"); +console.log(transform(code2, "NoConstructor")); + +console.log("--- Test 3 ---"); +console.log(transform(code3, "ExistingName")); From c7fad6280be9d57868b5bda2896579eddef1a4f6 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 26 Nov 2025 18:36:50 +0300 Subject: [PATCH 20/46] working fixing virtual --- build/vite-plugin-openscript.js | 48 ++++++++++++++++++++++----------- package.json | 5 +++- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/build/vite-plugin-openscript.js b/build/vite-plugin-openscript.js index d9084db..2e19038 100644 --- a/build/vite-plugin-openscript.js +++ b/build/vite-plugin-openscript.js @@ -1,6 +1,7 @@ import fs from "fs"; import path from "path"; import { normalizePath } from "vite"; +import MagicString from "magic-string"; /** * OpenScript Component Auto-Import Plugin @@ -59,7 +60,12 @@ export function openScriptComponentPlugin(options = {}) { load(id) { if (id === resolvedVirtualModuleId) { // Generate virtual module that imports all components - return generateVirtualModule(componentsDir, components, autoRegister); + return generateVirtualModule( + config.root, + componentsDir, + components, + autoRegister + ); } }, @@ -74,32 +80,39 @@ export function openScriptComponentPlugin(options = {}) { const className = classMatch[1]; // If code already sets this.name explicitly, skip (simple check) - // We still use the runtime check (!this.name) to be safe, but this avoids double injection if we run multiple times if (code.includes(`this.name = "${className}"`)) return; + const s = new MagicString(code); + if (code.includes("constructor")) { // Inject after super() - // Matches super(...) or super() with optional semicolon - return code.replace( - /(super\s*\([^)]*\)\s*;?)/, - `$1\n if (!this.name) this.name = "${className}";` - ); + const superMatch = code.match(/(super\s*\([^)]*\)\s*;?)/); + if (superMatch) { + const index = superMatch.index + superMatch[0].length; + s.appendRight( + index, + `\n if (!this.name) this.name = "${className}";` + ); + } } else { // No constructor, inject one - // Find the first opening brace after class definition const classDef = classMatch[0]; const openBraceIndex = code.indexOf("{", code.indexOf(classDef)); if (openBraceIndex !== -1) { - return ( - code.slice(0, openBraceIndex + 1) + - `\n constructor() { super(); this.name = "${className}"; }` + - code.slice(openBraceIndex + 1) + s.appendRight( + openBraceIndex + 1, + `\n constructor() { super(); this.name = "${className}"; }` ); } } - return code; + if (s.hasChanged()) { + return { + code: s.toString(), + map: s.generateMap({ source: id, includeContent: true }), + }; + } }, // HMR support @@ -195,9 +208,14 @@ export {}; /** * Generate virtual module content that imports and registers all components */ -function generateVirtualModule(componentsDir, components, autoRegister) { +function generateVirtualModule(root, componentsDir, components, autoRegister) { const imports = components - .map((c) => `import ${c.name} from '../${componentsDir}/${c.path}';`) + .map((c) => { + const absolutePath = normalizePath( + path.resolve(root, componentsDir, c.path) + ); + return `import ${c.name} from '${absolutePath}';`; + }) .join("\n"); const exports = components.map((c) => c.name).join(", "); diff --git a/package.json b/package.json index 605b636..73cad25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.9", + "version": "1.0.12", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", @@ -83,5 +83,8 @@ "terser": "^5.36.0", "vite": "^7.2.4", "vitest": "^4.0.14" + }, + "dependencies": { + "magic-string": "^0.30.21" } } From 4a10a852b13ebd65c2d61b91fc464430947e73c4 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Thu, 27 Nov 2025 09:58:49 +0300 Subject: [PATCH 21/46] add recursive scanning to the package --- build/vite-plugin-openscript.js | 29 +++++++++++++++++++++-------- package.json | 2 +- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/build/vite-plugin-openscript.js b/build/vite-plugin-openscript.js index 2e19038..ee3f83d 100644 --- a/build/vite-plugin-openscript.js +++ b/build/vite-plugin-openscript.js @@ -169,16 +169,29 @@ function scanComponents(dir, basePath = "") { * Generate TypeScript definition file for IDE autocomplete */ function generateTypeDefinitions(root, componentsDir, components) { - const dtsPath = path.resolve(root, "src/openscript-components.d.ts"); + // Place d.ts in the parent directory of componentsDir + const componentsAbsDir = path.resolve(root, componentsDir); + const dtsDir = path.dirname(componentsAbsDir); + const dtsPath = path.resolve(dtsDir, "openscript-components.d.ts"); const imports = components - .map( - (c) => - `import type ${c.name} from './${componentsDir}/${c.path.replace( - ".js", - "" - )}';` - ) + .map((c) => { + const componentAbsPath = path.resolve(componentsAbsDir, c.path); + let relativePath = path.relative(dtsDir, componentAbsPath); + + // Normalize to forward slashes + relativePath = normalizePath(relativePath); + + // Remove extension + relativePath = relativePath.replace(/\.\w+$/, ""); + + // Ensure it starts with ./ or ../ + if (!relativePath.startsWith(".") && !relativePath.startsWith("/")) { + relativePath = "./" + relativePath; + } + + return `import type ${c.name} from '${relativePath}';`; + }) .join("\n"); const properties = components diff --git a/package.json b/package.json index 73cad25..ede6fd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.12", + "version": "1.0.14", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", From 322f2dc235fa60449b1f5c6f15e63005f76e392b Mon Sep 17 00:00:00 2001 From: levizwannah Date: Fri, 28 Nov 2025 12:22:15 +0300 Subject: [PATCH 22/46] fixing markup engine boot --- package.json | 2 +- src/component/Component.js | 1 + src/component/MarkupEngine.js | 1342 ++++++++++++++++----------------- 3 files changed, 671 insertions(+), 674 deletions(-) diff --git a/package.json b/package.json index ede6fd0..c363802 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.14", + "version": "1.0.18", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/component/Component.js b/src/component/Component.js index 41a68fb..7de0a7e 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -818,6 +818,7 @@ export default class Component { * @returns */ render(state, callback, ...args) { + let h = container.resolve("h"); let markup = callback(state, ...args); return h[`ojs-wrapper`](markup, ...args); } diff --git a/src/component/MarkupEngine.js b/src/component/MarkupEngine.js index a1d002f..a11cff0 100644 --- a/src/component/MarkupEngine.js +++ b/src/component/MarkupEngine.js @@ -8,677 +8,673 @@ import { container } from "../core/Container.js"; * Base Markup Engine Class */ export default class MarkupEngine { - /** - * The IDs for components on the DOM awaiting - * rendering - */ - static ID = 0; - - constructor() { - /** - * Keeps the components - * @type {Map} - */ - this.compMap = new Map(); - - /** - * Keeps the components arguments - * @type {Map} - */ - this.compArgs = new Map(); - - /** - * Keeps a temporary component-events map - * @type {Map>} - */ - this.eventsMap = new Map(); - - this.reconciler = new DOMReconciler(); - - /** - * References the DOM object - */ - this.dom = window.document; - - /** - * - * @param {string} name component name - * @param {Component} component OpenScript component for rendering. - * - * - * @return {HTMLElement|Array} - */ - this.component = (name, component) => { - if (!(typeof name === "string")) { - throw Error( - `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` - ); - } - - if (!(component instanceof Component)) { - throw new Error( - `MarkupEngine.Exception: The component for ${name} must be an Component component. ${component.constructor.name} given` - ); - } - - this.compMap.set(name, component); - }; - - /** - * Deletes the component from the Markup Engine Map. - * @emits unmount - * Removes an already registered company - * @param {string} name - * @param {boolean} withMarkup remove the markup of this component - * as well. - * @returns {boolean} - */ - this.deleteComponent = (name, withMarkup = true) => { - if (!this.has(name)) { - return false; - } - - if (withMarkup) this.getComponent(name).unmount(); - - this.getComponent(name).emit("unmount"); - - return this.compMap.delete(name); - }; - - /** - * Checks if a component is registered with the - * markup engine. - * @param {string} name - * @returns - */ - this.has = (name) => { - return this.compMap.has(name); - }; - - /** - * Checks if a component is registered - * @param {string} name - * @param {string} method method name - * @returns - */ - this.isRegistered = (name, method = "access") => { - if (this.has(name)) return true; - - console.warn( - `MarkupEngine.Warn: Trying to ${method} an unregistered component {${name}}. Please ensure that the component is registered by using h.has(componentName)` - ); - - return false; - }; - - this.reconcile = (domNode, newNode) => { - this.reconciler.reconcile(newNode, domNode); - }; - - /** - * Removes all a component's markup - * from the DOM - * @param {string} name - */ - this.hide = (name) => { - if (!this.isRegistered(name, "hide")) return false; - - const c = this.getComponent(name); - c.hide(); - - return true; - }; - - /** - * make all the component visible - * @param {string} name component name - * @returns - */ - this.show = (name) => { - if (!this.isRegistered(name, "show")) return false; - - const c = this.getComponent(name); - c.show(); - - return true; - }; - - this.modify = (element) => { - element.__eventListeners = element.__eventListeners ?? {}; - - element.addListener = function (event, listener) { - this.__eventListeners[event] = - this.__eventListeners[event] ?? []; - this.__eventListeners[event].push(listener); - this.addEventListener(event, listener); - }; - - element.removeListener = function (event, listener) { - this.__eventListeners[event] = this.__eventListeners[ - event - ]?.filter((x) => x !== listener); - - this.removeEventListener(event, listener); - }; - - element.getEventListeners = function () { - return this.__eventListeners; - }; - - if (!element.__methods) { - element.__methods = {}; - } - - element.methods = function () { - let methods = {}; - - for (let m in this.__methods) { - methods[m] = this.__methods[m].bind(this); - } - - return methods; - }; - }; - - this.fromString = (string, outerElement = "div", ...args) => { - let elem = h[outerElement](...args); - elem.innerHTML = string; - return elem; - }; - - /** - * handles the DOM element creation - * @param {string} name - * @param {...any} args - */ - this.handle = (name, ...args) => { - if (!(typeof name === "string")) { - throw Error( - `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` - ); - } - - if (/^[_\$]+$/.test(name)) { - name = Component.FRAGMENT.toLowerCase(); - } - - let isSvg = false; - - if (/^\$\w+$/.test(name)) { - name = name.substring(1); - isSvg = true; - } - - /** - * If this is a component, return it - */ - - if (this.compMap.has(name)) { - return this.compMap.get(name).wrap(...args); - } - - let component; - let event = ""; - let eventParams = []; - - const isComponentName = (tag) => { - return /^ojs-.*$/.test(tag); - }; - - /** - * - * @param {string} tag - */ - const getComponentName = (tag) => { - let name = tag - .toLowerCase() - .replace(/^ojs-/, "") - .replace(/-tmp--$/, ""); - - return Utils.camel(name, true); - }; - - /** - * @type {DocumentFragment|HTMLElement} - */ - let parent = null; - - let emptyParent = false; - let replaceParent = false; - let prependToParent = false; - let rootFrag = new DocumentFragment(); - - const isUpperCase = (string) => /^[A-Z]*$/.test(string); - let isComponent = isUpperCase(name[0]); - - /** - * @type {HTMLElement} - */ - let root = null; - - let componentAttribute = {}; - let withCAttr = false; - - /** - * When dealing with a component - * save the argument for async rendering - */ - if (isComponent) { - root = this.dom.createElement(`ojs-${Utils.kebab(name)}-tmp--`); - - let id = `ojs-${Utils.kebab(name)}-${MarkupEngine.ID++}`; - - root.setAttribute("ojs-key", id); - root.setAttribute("class", "__ojs-c-class__"); - - this.compArgs.set(id, args); - } else { - root = isSvg - ? this.dom.createElementNS( - "http://www.w3.org/2000/svg", - name - ) - : this.dom.createElement(name); - } - - this.modify(root); - - let parseAttr = (obj) => { - for (let k in obj) { - let v = obj[k]; - - if (v instanceof State) { - v = v.value; - } - - if (k === "parent" && v instanceof HTMLElement) { - parent = v; - continue; - } - - if (k === "resetParent" && typeof v === "boolean") { - emptyParent = v; - continue; - } - - if (k === "firstOfParent" && typeof v === "boolean") { - prependToParent = v; - continue; - } - - if (k === "event" && typeof v === "string") { - event = v; - continue; - } - - if (k === "replaceParent" && typeof v === "boolean") { - replaceParent = v; - continue; - } - - if (k === "eventParams") { - if (!Array.isArray(v)) v = [v]; - eventParams = v; - continue; - } - - if (k === "component" && v instanceof Component) { - component = v; - continue; - } - - if (k === "c_attr") { - componentAttribute = v; - continue; - } - - if (k.length && k[0] === "$") { - componentAttribute[k.substring(1)] = v; - continue; - } - - if (k === "withCAttr") { - withCAttr = true; - continue; - } - - if (k === "listeners") { - if (typeof v !== "object") { - throw TypeError( - `The value of 'listeners' should be an object. but found ${typeof v}` - ); - } - - for (let evt in v) { - let listener = v[evt]; - - if (Array.isArray(listener)) { - listener.forEach((l) => - root.addListener(evt, l) - ); - } else { - root.addListener(evt, listener); - } - } - - continue; - } - - if (k === "methods") { - if (typeof v !== "object") { - throw TypeError( - `The value of 'methods' attribute should be an object. but found ${typeof v}` - ); - } - - for (let method in v) { - let func = v[method]; - root.__methods[method] = func; - } - - continue; - } - - let val = `${v}`; - if (Array.isArray(v)) val = `${v.join(" ")}`; - - k = k.replace(/_/g, "-"); - - if (k === "class" || k === "Class") { - let cls = root.getAttribute(k) ?? ""; - val = cls + (cls.length > 0 ? " " : "") + `${val}`; - } - - try { - root.setAttribute(k, val); - } catch (e) { - console.error( - `MarkupEngine.ParseAttribute.Exception: `, - e, - `. Attributes resulting in the error: `, - obj - ); - throw Error(e); - } - } - }; - - const parse = (arg, isComp) => { - if ( - arg instanceof DocumentFragment || - arg instanceof HTMLElement || - arg instanceof SVGElement || - arg instanceof State - ) { - if (isComp) return true; - - if (arg instanceof State) { - typeof arg.value === "string" && - rootFrag.append(document.createTextNode(arg)); - } else { - rootFrag.append(arg); - } - - return true; - } - - if (typeof arg === "object") { - parseAttr(arg); - return true; - } - - if (typeof arg !== "undefined") { - rootFrag.append(arg); - return true; - } - - return false; - }; - - for (let arg of args) { - if (isComponent && parent) break; - - // if (arg instanceof State) continue; - - if ( - Array.isArray(arg) || - arg instanceof HTMLCollection || - arg instanceof NodeList - ) { - if (isComponent) continue; - - arg.forEach((e) => { - if (e) parse(e, isComponent); - }); - - continue; - } - - if (parse(arg, isComponent)) continue; - - if (isComponent) continue; - - let v = this.toElement(arg); - if (typeof v !== "undefined") rootFrag.append(v); - } - - root.append(rootFrag); - - if (withCAttr) { - let atr = JSON.stringify(componentAttribute); - if (atr) root.setAttribute("c-attr", atr); - } - - root.toString = function () { - return this.outerHTML; - }; - - if (parent) { - if (emptyParent) { - parent.textContent = ""; - } - - if (replaceParent) { - this.reconcile(parent, root); - } else if (prependToParent) { - parent.prepend(root); - } else { - parent.append(root); - } - } - - if (component) { - component.emit(event, eventParams); - - let sc = root.querySelectorAll(".__ojs-c-class__"); - sc.forEach((c) => { - if (!isComponentName(c.tagName.toLowerCase())) return; - let cmpName = getComponentName(c.tagName); - const h = container.resolve("h"); - h.getComponent(cmpName)?.emit(event, eventParams); - }); - } - - return root; - }; - - /** - * Executes a function that returns an - * HTMLElement and adds that element to the overall markup. - * @param {function} f - This function should return an HTMLElement or a string or an Array of either - * @returns {HTMLElement|string|Array} - */ - this.call = (f = () => h["ojs-group"]()) => { - return f(); - }; - - /** - * Allows you to add functions to HTML elements - * @param {Array} ComponentAndMethod name of the method - * @param {...any} args arguments to pass to the method - * @returns - */ - this.func = (name, ...args) => { - let method = null; - let component = null; - - if (!Array.isArray(name)) { - method = name; - return `${method}(${this._escape(args)})`; - } - - method = name[1]; - component = name[0]; - - return `component('${component.name}')['${method}'](${this._escape( - args - )})`; - }; - - /** - * - * adds quotes to string arguments - * and serializes objects for - * param passing - * @note To escape adding quotes use ${string} - */ - this._escape = (args) => { - let final = []; - - for (let e of args) { - if (typeof e === "number") final.push(e); - else if (typeof e === "boolean") final.push(e); - else if (typeof e === "string") { - if (e.length && e.substring(0, 2) === "${") { - let length = - e[e.length - 1] === "}" ? e.length - 1 : e.length; - final.push(e.substring(2, length)); - } else final.push(`'${e}'`); - } else if (typeof e === "object") final.push(JSON.stringify(e)); - } - - return final; - }; - - this.__addToEventsMap = (component, event, listeners) => { - if (!this.eventsMap.has(component)) { - this.eventsMap.set(component, {}); - this.eventsMap.get(component)[event] = listeners; - return; - } - - if (!this.eventsMap.get(component)[event]) { - this.eventsMap.get(component)[event] = []; - } - - this.eventsMap.get(component)[event].push(...listeners); - }; - - /** - * Adds an event listener to a component - * @param {string|Array} component component name - * @param {string} event event name - * @param {...function} listeners listeners - */ - this.on = (component, event, ...listeners) => { - let components = component; - - if (!Array.isArray(component)) components = [component]; - - for (let component of components) { - if (/\./.test(component)) { - let tmp = component.split(".").filter((e) => e); - component = tmp[0]; - listeners.push(event); - event = tmp[1]; - } - - if (this.has(component)) { - this.getComponent(component).on(event, ...listeners); - - continue; - } - - listeners.forEach((f, i) => { - listeners[i] = { type: "after", function: f }; - }); - - this.__addToEventsMap(component, event, listeners); - } - }; - - /** - * Add events listeners to a component that will - * execute even after the event has been emitted - * @param {string|Array} component - * @param {string} event - * @param {...function} listeners - */ - this.onAll = (component, event, ...listeners) => { - let components = component; - - if (!Array.isArray(component)) components = [component]; - - for (let component of components) { - if (/\./.test(component)) { - let tmp = component.split(".").filter((e) => e); - component = tmp[0]; - listeners.push(event); - event = tmp[1]; - } - - if (this.has(component)) { - this.getComponent(component).onAll(event, ...listeners); - continue; - } - - listeners.forEach((f, i) => { - listeners[i] = { type: "all", function: f }; - }); - - this.__addToEventsMap(component, event, listeners); - } - }; - - /** - * Gets the event emitter of a component - * @param {string} component component name - * @returns - */ - this.emitter = (component) => { - return this.compMap.get(component)?.emitter; - }; - - /** - * Gets a component and returns it - * @param {string} name - * @returns {Component|null} - */ - this.getComponent = (name) => { - return this.compMap.get(name); - }; - - /** - * Creates an anonymous component - * around a state - * @param {State} state - * @param {Array} attribute attribute path - * @returns - */ - this.$anonymous = ( - state, - callback = (state) => state.value, - ...args - ) => { - return h[Component.anonymous()](state, callback, ...args); - }; - - /** - * Converts a value to HTML element; - * @param {string|HTMLElement} value - */ - this.toElement = (value) => { - return value; - }; - } + /** + * The IDs for components on the DOM awaiting + * rendering + */ + static ID = 0; + + constructor() { + /** + * Keeps the components + * @type {Map} + */ + this.compMap = new Map(); + + /** + * Keeps the components arguments + * @type {Map} + */ + this.compArgs = new Map(); + + /** + * Keeps a temporary component-events map + * @type {Map>} + */ + this.eventsMap = new Map(); + + this.reconciler = new DOMReconciler(); + + /** + * References the DOM object + */ + this.dom = window.document; + + /** + * + * @param {string} name component name + * @param {Component} component OpenScript component for rendering. + * + * + * @return {HTMLElement|Array} + */ + this.component = (name, component) => { + if (!(typeof name === "string")) { + throw Error( + `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` + ); + } + + if (!(component instanceof Component)) { + throw new Error( + `MarkupEngine.Exception: The component for ${name} must be an Component component. ${component.constructor.name} given` + ); + } + + this.compMap.set(name, component); + }; + + /** + * Deletes the component from the Markup Engine Map. + * @emits unmount + * Removes an already registered company + * @param {string} name + * @param {boolean} withMarkup remove the markup of this component + * as well. + * @returns {boolean} + */ + this.deleteComponent = (name, withMarkup = true) => { + if (!this.has(name)) { + return false; + } + + if (withMarkup) this.getComponent(name).unmount(); + + this.getComponent(name).emit("unmount"); + + return this.compMap.delete(name); + }; + + /** + * Checks if a component is registered with the + * markup engine. + * @param {string} name + * @returns + */ + this.has = (name) => { + return this.compMap.has(name); + }; + + /** + * Checks if a component is registered + * @param {string} name + * @param {string} method method name + * @returns + */ + this.isRegistered = (name, method = "access") => { + if (this.has(name)) return true; + + console.warn( + `MarkupEngine.Warn: Trying to ${method} an unregistered component {${name}}. Please ensure that the component is registered by using h.has(componentName)` + ); + + return false; + }; + + this.reconcile = (domNode, newNode) => { + this.reconciler.reconcile(newNode, domNode); + }; + + /** + * Removes all a component's markup + * from the DOM + * @param {string} name + */ + this.hide = (name) => { + if (!this.isRegistered(name, "hide")) return false; + + const c = this.getComponent(name); + c.hide(); + + return true; + }; + + /** + * make all the component visible + * @param {string} name component name + * @returns + */ + this.show = (name) => { + if (!this.isRegistered(name, "show")) return false; + + const c = this.getComponent(name); + c.show(); + + return true; + }; + + this.modify = (element) => { + element.__eventListeners = element.__eventListeners ?? {}; + + element.addListener = function (event, listener) { + this.__eventListeners[event] = this.__eventListeners[event] ?? []; + this.__eventListeners[event].push(listener); + this.addEventListener(event, listener); + }; + + element.removeListener = function (event, listener) { + this.__eventListeners[event] = this.__eventListeners[event]?.filter( + (x) => x !== listener + ); + + this.removeEventListener(event, listener); + }; + + element.getEventListeners = function () { + return this.__eventListeners; + }; + + if (!element.__methods) { + element.__methods = {}; + } + + element.methods = function () { + let methods = {}; + + for (let m in this.__methods) { + methods[m] = this.__methods[m].bind(this); + } + + return methods; + }; + }; + + this.fromString = (string, outerElement = "div", ...args) => { + const h = container.resolve("h"); + let elem = h[outerElement](...args); + elem.innerHTML = string; + return elem; + }; + + /** + * handles the DOM element creation + * @param {string} name + * @param {...any} args + */ + this.handle = (name, ...args) => { + if (!(typeof name === "string")) { + throw Error( + `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` + ); + } + + if (/^[_\$]+$/.test(name)) { + name = Component.FRAGMENT.toLowerCase(); + } + + let isSvg = false; + + if (/^\$\w+$/.test(name)) { + name = name.substring(1); + isSvg = true; + } + + /** + * If this is a component, return it + */ + + if (this.compMap.has(name)) { + return this.compMap.get(name).wrap(...args); + } + + let component; + let event = ""; + let eventParams = []; + + const isComponentName = (tag) => { + return /^ojs-.*$/.test(tag); + }; + + /** + * + * @param {string} tag + */ + const getComponentName = (tag) => { + let name = tag + .toLowerCase() + .replace(/^ojs-/, "") + .replace(/-tmp--$/, ""); + + return Utils.camel(name, true); + }; + + /** + * @type {DocumentFragment|HTMLElement} + */ + let parent = null; + + let emptyParent = false; + let replaceParent = false; + let prependToParent = false; + let rootFrag = new DocumentFragment(); + + const isUpperCase = (string) => /^[A-Z]*$/.test(string); + let isComponent = isUpperCase(name[0]); + + /** + * @type {HTMLElement} + */ + let root = null; + + let componentAttribute = {}; + let withCAttr = false; + + /** + * When dealing with a component + * save the argument for async rendering + */ + if (isComponent) { + root = this.dom.createElement(`ojs-${Utils.kebab(name)}-tmp--`); + + let id = `ojs-${Utils.kebab(name)}-${MarkupEngine.ID++}`; + + root.setAttribute("ojs-key", id); + root.setAttribute("class", "__ojs-c-class__"); + + this.compArgs.set(id, args); + } else { + root = isSvg + ? this.dom.createElementNS("http://www.w3.org/2000/svg", name) + : this.dom.createElement(name); + } + + this.modify(root); + + let parseAttr = (obj) => { + for (let k in obj) { + let v = obj[k]; + + if (v instanceof State) { + v = v.value; + } + + if (k === "parent" && v instanceof HTMLElement) { + parent = v; + continue; + } + + if (k === "resetParent" && typeof v === "boolean") { + emptyParent = v; + continue; + } + + if (k === "firstOfParent" && typeof v === "boolean") { + prependToParent = v; + continue; + } + + if (k === "event" && typeof v === "string") { + event = v; + continue; + } + + if (k === "replaceParent" && typeof v === "boolean") { + replaceParent = v; + continue; + } + + if (k === "eventParams") { + if (!Array.isArray(v)) v = [v]; + eventParams = v; + continue; + } + + if (k === "component" && v instanceof Component) { + component = v; + continue; + } + + if (k === "c_attr") { + componentAttribute = v; + continue; + } + + if (k.length && k[0] === "$") { + componentAttribute[k.substring(1)] = v; + continue; + } + + if (k === "withCAttr") { + withCAttr = true; + continue; + } + + if (k === "listeners") { + if (typeof v !== "object") { + throw TypeError( + `The value of 'listeners' should be an object. but found ${typeof v}` + ); + } + + for (let evt in v) { + let listener = v[evt]; + + if (Array.isArray(listener)) { + listener.forEach((l) => root.addListener(evt, l)); + } else { + root.addListener(evt, listener); + } + } + + continue; + } + + if (k === "methods") { + if (typeof v !== "object") { + throw TypeError( + `The value of 'methods' attribute should be an object. but found ${typeof v}` + ); + } + + for (let method in v) { + let func = v[method]; + root.__methods[method] = func; + } + + continue; + } + + let val = `${v}`; + if (Array.isArray(v)) val = `${v.join(" ")}`; + + k = k.replace(/_/g, "-"); + + if (k === "class" || k === "Class") { + let cls = root.getAttribute(k) ?? ""; + val = cls + (cls.length > 0 ? " " : "") + `${val}`; + } + + try { + root.setAttribute(k, val); + } catch (e) { + console.error( + `MarkupEngine.ParseAttribute.Exception: `, + e, + `. Attributes resulting in the error: `, + obj + ); + throw Error(e); + } + } + }; + + const parse = (arg, isComp) => { + if ( + arg instanceof DocumentFragment || + arg instanceof HTMLElement || + arg instanceof SVGElement || + arg instanceof State + ) { + if (isComp) return true; + + if (arg instanceof State) { + typeof arg.value === "string" && + rootFrag.append(document.createTextNode(arg)); + } else { + rootFrag.append(arg); + } + + return true; + } + + if (typeof arg === "object") { + parseAttr(arg); + return true; + } + + if (typeof arg !== "undefined") { + rootFrag.append(arg); + return true; + } + + return false; + }; + + for (let arg of args) { + if (isComponent && parent) break; + + // if (arg instanceof State) continue; + + if ( + Array.isArray(arg) || + arg instanceof HTMLCollection || + arg instanceof NodeList + ) { + if (isComponent) continue; + + arg.forEach((e) => { + if (e) parse(e, isComponent); + }); + + continue; + } + + if (parse(arg, isComponent)) continue; + + if (isComponent) continue; + + let v = this.toElement(arg); + if (typeof v !== "undefined") rootFrag.append(v); + } + + root.append(rootFrag); + + if (withCAttr) { + let atr = JSON.stringify(componentAttribute); + if (atr) root.setAttribute("c-attr", atr); + } + + root.toString = function () { + return this.outerHTML; + }; + + if (parent) { + if (emptyParent) { + parent.textContent = ""; + } + + if (replaceParent) { + this.reconcile(parent, root); + } else if (prependToParent) { + parent.prepend(root); + } else { + parent.append(root); + } + } + + if (component) { + component.emit(event, eventParams); + + let sc = root.querySelectorAll(".__ojs-c-class__"); + sc.forEach((c) => { + if (!isComponentName(c.tagName.toLowerCase())) return; + let cmpName = getComponentName(c.tagName); + const h = container.resolve("h"); + h.getComponent(cmpName)?.emit(event, eventParams); + }); + } + + return root; + }; + + /** + * Executes a function that returns an + * HTMLElement and adds that element to the overall markup. + * @param {function} f - This function should return an HTMLElement or a string or an Array of either + * @returns {HTMLElement|string|Array} + */ + this.call = ( + f = () => { + const h = container.resolve("h"); + return h["ojs-group"](); + } + ) => { + return f(); + }; + + /** + * Allows you to add functions to HTML elements + * @param {Array} ComponentAndMethod name of the method + * @param {...any} args arguments to pass to the method + * @returns + */ + this.func = (name, ...args) => { + let method = null; + let component = null; + + if (!Array.isArray(name)) { + method = name; + return `${method}(${this._escape(args)})`; + } + + method = name[1]; + component = name[0]; + + return `component('${component.name}')['${method}'](${this._escape( + args + )})`; + }; + + /** + * + * adds quotes to string arguments + * and serializes objects for + * param passing + * @note To escape adding quotes use ${string} + */ + this._escape = (args) => { + let final = []; + + for (let e of args) { + if (typeof e === "number") final.push(e); + else if (typeof e === "boolean") final.push(e); + else if (typeof e === "string") { + if (e.length && e.substring(0, 2) === "${") { + let length = e[e.length - 1] === "}" ? e.length - 1 : e.length; + final.push(e.substring(2, length)); + } else final.push(`'${e}'`); + } else if (typeof e === "object") final.push(JSON.stringify(e)); + } + + return final; + }; + + this.__addToEventsMap = (component, event, listeners) => { + if (!this.eventsMap.has(component)) { + this.eventsMap.set(component, {}); + this.eventsMap.get(component)[event] = listeners; + return; + } + + if (!this.eventsMap.get(component)[event]) { + this.eventsMap.get(component)[event] = []; + } + + this.eventsMap.get(component)[event].push(...listeners); + }; + + /** + * Adds an event listener to a component + * @param {string|Array} component component name + * @param {string} event event name + * @param {...function} listeners listeners + */ + this.on = (component, event, ...listeners) => { + let components = component; + + if (!Array.isArray(component)) components = [component]; + + for (let component of components) { + if (/\./.test(component)) { + let tmp = component.split(".").filter((e) => e); + component = tmp[0]; + listeners.push(event); + event = tmp[1]; + } + + if (this.has(component)) { + this.getComponent(component).on(event, ...listeners); + + continue; + } + + listeners.forEach((f, i) => { + listeners[i] = { type: "after", function: f }; + }); + + this.__addToEventsMap(component, event, listeners); + } + }; + + /** + * Add events listeners to a component that will + * execute even after the event has been emitted + * @param {string|Array} component + * @param {string} event + * @param {...function} listeners + */ + this.onAll = (component, event, ...listeners) => { + let components = component; + + if (!Array.isArray(component)) components = [component]; + + for (let component of components) { + if (/\./.test(component)) { + let tmp = component.split(".").filter((e) => e); + component = tmp[0]; + listeners.push(event); + event = tmp[1]; + } + + if (this.has(component)) { + this.getComponent(component).onAll(event, ...listeners); + continue; + } + + listeners.forEach((f, i) => { + listeners[i] = { type: "all", function: f }; + }); + + this.__addToEventsMap(component, event, listeners); + } + }; + + /** + * Gets the event emitter of a component + * @param {string} component component name + * @returns + */ + this.emitter = (component) => { + return this.compMap.get(component)?.emitter; + }; + + /** + * Gets a component and returns it + * @param {string} name + * @returns {Component|null} + */ + this.getComponent = (name) => { + return this.compMap.get(name); + }; + + /** + * Creates an anonymous component + * around a state + * @param {State} state + * @param {Array} attribute attribute path + * @returns + */ + this.$anonymous = (state, callback = (state) => state.value, ...args) => { + const h = container.resolve("h"); + return h[Component.anonymous()](state, callback, ...args); + }; + + /** + * Converts a value to HTML element; + * @param {string|HTMLElement} value + */ + this.toElement = (value) => { + return value; + }; + } } From 08a18fc7060063664e7ffe2773e5bd9f13aa733a Mon Sep 17 00:00:00 2001 From: levizwannah Date: Tue, 2 Dec 2025 13:19:44 +0300 Subject: [PATCH 23/46] removed duplicate event registration for components on the broker --- package.json | 2 +- src/component/Component.js | 8 ++++++++ src/index.js | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c363802..f474ee4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.18", + "version": "1.0.22", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/component/Component.js b/src/component/Component.js index 7de0a7e..39844a8 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -254,6 +254,14 @@ export default class Component { * Get all Emitters declared in the component */ getDeclaredListeners() { + + if (this.__ojsRegistered) { + console.warn( + `Component "${this.name}" is already registered. Skipping duplicate registration.` + ); + return; + } + let obj = this; let seen = new Set(); const h = container.resolve("h"); diff --git a/src/index.js b/src/index.js index d1b7b75..77e4cd5 100644 --- a/src/index.js +++ b/src/index.js @@ -35,6 +35,7 @@ const loader = new AutoLoader(); const autoload = new AutoLoader(); const h = MarkupHandler.proxy(); + // Register global instances in container container.value("broker", broker); container.value("router", router); @@ -43,6 +44,7 @@ container.value("mediatorManager", mediatorManager); container.value("loader", loader); container.value("autoload", autoload); container.value("h", h); +container.value("component", component); let ojsRouterEvents = { ojs: { From b4e01f396bbf242e4fc8d7801afe68cac177888a Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 3 Dec 2025 09:10:20 +0300 Subject: [PATCH 24/46] initiated fixing leaks --- package.json | 2 +- src/component/Component.js | 4 ---- src/core/Container.js | 5 +++-- src/core/Runner.js | 22 ++++++++++++++++------ src/index.js | 5 +++-- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index f474ee4..70415a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.22", + "version": "1.0.30", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/component/Component.js b/src/component/Component.js index 39844a8..7807cae 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -105,7 +105,6 @@ export default class Component { this.on(this.EVENTS.bound, (th) => (th.bound = true)); this.on(this.EVENTS.mounted, (th) => (th.mounted = true)); this.on(this.EVENTS.visible, (th) => (th.visible = true)); - this.getDeclaredListeners(); this.$$ojs = { routeChanged: () => { @@ -360,9 +359,6 @@ export default class Component { this.emit(this.EVENTS.premount); await this.bindComponent(); this.emit(this.EVENTS.mounted); - - // Mark as registered - this.__ojsRegistered = true; } /** diff --git a/src/core/Container.js b/src/core/Container.js index ad74a07..db44863 100644 --- a/src/core/Container.js +++ b/src/core/Container.js @@ -90,9 +90,10 @@ export default class Container { /** * Resolve a service by name * @param {string} name - Service identifier + * @param {any} defaultValue - Default value to return if service is not found * @returns {any} - The resolved service instance */ - resolve(name) { + resolve(name, defaultValue = null) { // Check for circular dependencies if (this.resolvingStack.has(name)) { const stack = Array.from(this.resolvingStack).join(" -> "); @@ -101,7 +102,7 @@ export default class Container { const service = this.services.get(name); if (!service) { - throw new Error(`Service "${name}" not registered in container`); + return defaultValue; } // Return cached singleton instance diff --git a/src/core/Runner.js b/src/core/Runner.js index 71c0e19..5441904 100644 --- a/src/core/Runner.js +++ b/src/core/Runner.js @@ -23,40 +23,50 @@ export default class Runner { } async run(...cls) { + container.value( + "__ojs_registrations", + container.resolve("__ojs_registrations") ?? {} + ); + + const registrations = container.resolve("__ojs_registrations"); + for (let i = 0; i < cls.length; i++) { let c = cls[i]; let instance; + const classKey = this.getClassKey(c); if (!this.isClass(c)) { // Functional component - always create new instance (not a singleton) instance = new Component(c.name); instance.render = c.bind(instance); } else { - // For classes, check if singleton exists in container - const classKey = this.getClassKey(c); + if (registrations[classKey] === "ongoing") { + continue; + } if (container.has(classKey)) { - // Retrieve existing singleton from container instance = container.resolve(classKey); - - // Skip if already registered (has __ojsRegistered flag) if (instance.__ojsRegistered) { continue; } } else { - // Create new instance and register as singleton in container instance = new c(); container.singleton(classKey, () => instance, []); + registrations[classKey] = "ongoing"; } } if (instance instanceof Component) { instance.getDeclaredListeners(); await instance.mount(); + instance.__ojsRegistered = true; + registrations[classKey] = "completed"; } else if (instance instanceof Mediator || instance instanceof Listener) { await instance.register(); + registrations[classKey] = "completed"; } else if (instance instanceof Context) { // Context instances don't need registration + registrations[classKey] = "completed"; } else { throw Error( `You can only pass declarations which extend Component, Mediator or Listener` diff --git a/src/index.js b/src/index.js index 77e4cd5..dae90ef 100644 --- a/src/index.js +++ b/src/index.js @@ -127,12 +127,13 @@ const dom = DOM; /** * Access a service from the IoC container or get the container itself * @param {string|undefined} [instance] - Service name or undefined to get container + * @param {any} [defaultValue] - Default value to return if service is not found * @returns {any} */ -const app = (instance = null) => { +const app = (instance = null, defaultValue = null) => { if (instance === null) return container; - return container.resolve(instance); + return container.resolve(instance, defaultValue); }; // Export everything From 6d03e20a666b0dbf19eeae24514d97e4b63aaed1 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Sun, 7 Dec 2025 23:51:42 +0300 Subject: [PATCH 25/46] working on openscriptjs --- package.json | 2 +- src/router/Router.js | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 70415a8..7314061 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.30", + "version": "1.0.31", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/router/Router.js b/src/router/Router.js index 7ac4261..3ebb84f 100644 --- a/src/router/Router.js +++ b/src/router/Router.js @@ -475,11 +475,27 @@ export default class Router { * @returns */ is(nameOrRoute) { - if (nameOrRoute == this.__resolved) return true; + //if the nameOrRoute is a route, remove the trailing slash + if (nameOrRoute.endsWith("/")) { + nameOrRoute = nameOrRoute.slice(0, -1); + } + + let resolved = this.__resolved; + + if (resolved.endsWith("/")) { + resolved = resolved.slice(0, -1); + } + + if (nameOrRoute == resolved) return true; for (let [n, r] of this.nameMap) { if (n == nameOrRoute) { - return r == this.__resolved; + + if (r.endsWith("/")) { + r = r.slice(0, -1); + } + + return r == resolved; } } From 8f084d5f7761ccf772959a2203f95aea22ff0184 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Mon, 5 Jan 2026 16:58:13 +0300 Subject: [PATCH 26/46] working on fixing ojs rendering --- src/broker/Broker.js | 18 ++++++++++++++++++ src/broker/BrokerRegistrar.js | 7 +++++++ src/component/Component.js | 27 +++++++++++++++++++++++---- src/component/MarkupEngine.js | 9 +++------ 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/broker/Broker.js b/src/broker/Broker.js index 7cdfe8d..29ae70a 100644 --- a/src/broker/Broker.js +++ b/src/broker/Broker.js @@ -68,6 +68,24 @@ export default class Broker { } } + off(events, listener) { + if (Array.isArray(events)) { + for (let event of events) { + this.off(event, listener); + } + + return; + } + + events = this.parseEvents(events); + + for (let event of events) { + event = event.trim(); + + this.#emitter.off(event, listener); + } + } + verifyEventRegistration(event) { if ( this.#emitOnlyRegisteredEvents && diff --git a/src/broker/BrokerRegistrar.js b/src/broker/BrokerRegistrar.js index 6b111c1..aedc33a 100644 --- a/src/broker/BrokerRegistrar.js +++ b/src/broker/BrokerRegistrar.js @@ -1,3 +1,4 @@ +import Component from "../component/Component.js"; import { container } from "../core/Container.js"; import { isClass } from "../utils/helpers.js"; @@ -65,6 +66,12 @@ export default class BrokerRegistrar { for (let ev of events) { if (ev.length === 0) continue; container.resolve("broker").on(ev, listener.bind(object)); + + if (object instanceof Component) { + object.__brokerEvents__ = object.__brokerEvents__ || {}; + object.__brokerEvents__[ev] = object.__brokerEvents__[ev] || []; + object.__brokerEvents__[ev].push(listener); + } } } } diff --git a/src/component/Component.js b/src/component/Component.js index 7807cae..ea80164 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -95,6 +95,12 @@ export default class Component { */ this.emitter = new Emitter(); + /** + * List of events that the component is listening to + * from the broker + */ + this.__brokerEvents__ = {}; + this.isAnonymous = false; this.name = name ?? this.constructor.name; @@ -167,7 +173,21 @@ export default class Component { } const h = container.resolve("h"); - return h.getComponent(splitted[0]).method(splitted[1], args); + let cls = h.getComponent(splitted[0]); + + if (!cls) { + console.error(`Component ${splitted[0]} not found`); + return; + } + + let obj = new cls(); + + if (!obj.method) { + console.error(`Method ${splitted[1]} not found in ${splitted[0]}`); + return; + } + + return obj.method(splitted[1], args); } /** @@ -253,7 +273,6 @@ export default class Component { * Get all Emitters declared in the component */ getDeclaredListeners() { - if (this.__ojsRegistered) { console.warn( `Component "${this.name}" is already registered. Skipping duplicate registration.` @@ -310,7 +329,7 @@ export default class Component { for (let j = 0; j < events.length; j++) { let ev = events[j]; - + if (!ev.length) continue; h[m](cmp, ev, (component, event, ...args) => { @@ -524,7 +543,7 @@ export default class Component { /** * Ensure that the action will get called - * even if the event was emitted previous + * even if the event was emitted previously * @param {string} event * @param {...function} listeners */ diff --git a/src/component/MarkupEngine.js b/src/component/MarkupEngine.js index a11cff0..ecaf7ec 100644 --- a/src/component/MarkupEngine.js +++ b/src/component/MarkupEngine.js @@ -43,10 +43,7 @@ export default class MarkupEngine { /** * * @param {string} name component name - * @param {Component} component OpenScript component for rendering. - * - * - * @return {HTMLElement|Array} + * @param {class} component OpenScript component class. */ this.component = (name, component) => { if (!(typeof name === "string")) { @@ -55,9 +52,9 @@ export default class MarkupEngine { ); } - if (!(component instanceof Component)) { + if (!(component.prototype instanceof Component)) { throw new Error( - `MarkupEngine.Exception: The component for ${name} must be an Component component. ${component.constructor.name} given` + `MarkupEngine.Exception: The component for ${name} must be an Component component. ${component.name} given` ); } From becc9890eb09be9c053729dbcdd63c269496186f Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 14 Jan 2026 22:57:58 +0300 Subject: [PATCH 27/46] fixing memory leaks --- src/broker/Broker.js | 12 +- src/component/Component.js | 465 ++------------- src/component/MarkupEngine.js | 979 ++++++++++++++------------------ src/core/Emitter.js | 130 +++-- src/core/Repository.js | 121 ++++ src/core/Runner.js | 8 +- src/core/State.js | 448 ++++++++------- src/index.js | 128 +++-- src/mediator/Mediator.js | 11 + src/mediator/MediatorManager.js | 47 -- src/router/Router.js | 2 +- src/utils/helpers.js | 59 +- 12 files changed, 1081 insertions(+), 1329 deletions(-) create mode 100644 src/core/Repository.js delete mode 100644 src/mediator/MediatorManager.js diff --git a/src/broker/Broker.js b/src/broker/Broker.js index 29ae70a..8b12068 100644 --- a/src/broker/Broker.js +++ b/src/broker/Broker.js @@ -1,4 +1,5 @@ import Emitter from "../core/Emitter.js"; +import EventData from "../core/EventData.js"; /** * The Broker Class @@ -227,15 +228,10 @@ export default class Broker { this.#logs[event] = this.#logs[event] ?? []; this.#logs[event].push({ timestamp: currentTime(), args: args }); - - // Import EventData dynamically or assume it's available? - // In original code: args.push(new OpenScript.EventData().encode()); - // I'll assume EventData is imported or I'll just skip this for now. - // Wait, I should import EventData. - // if (args.length == 0) { - // args.push(new OpenScript.EventData().encode()); - // } + if (args.length == 0) { + args.push((new EventData()).encode()); + } args.push(event); diff --git a/src/component/Component.js b/src/component/Component.js index ea80164..3a275c2 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -24,52 +24,32 @@ export default class Component { static FRAGMENT = "OJS-SPECIAL-FRAGMENT"; constructor(name = null) { + /** + * Component ID + */ + this.id = Component.uid++; + /** * List of events that the component emits */ this.EVENTS = { rendered: "rendered", // component is visible on the dom rerendered: "rerendered", // component was rerendered - premount: "premount", // component is ready to register mounted: "mounted", // the component is now registered - prebind: "prebind", // the component is ready to bind - bound: "bound", // the component has bound - markupBound: "markup-bound", // a temporary markup has bound - beforeHidden: "before-hidden", - hidden: "hidden", unmounted: "unmounted", // removed from the markup engine memory - beforeVisible: "before-visible", // before the markup is made visible - visible: "visible", // the markup is now made visible }; - /** - * List of all components that are listening to - * specific events - */ - this.listening = {}; - /** * All the states that this component is listening to * @type {object} */ this.states = {}; - /** - * List of components that this component is listening - * to. - */ - this.listeningTo = {}; - /** * Has the component being mounted */ this.mounted = false; - /** - * Has the component bound - */ - this.bound = false; - /** * Has the component rendered */ @@ -106,31 +86,23 @@ export default class Component { this.name = name ?? this.constructor.name; this.emitter.once(this.EVENTS.rendered, (th) => (th.rendered = true)); - this.on(this.EVENTS.hidden, (th) => (th.visible = false)); this.on(this.EVENTS.rerendered, (th) => (th.rerendered = true)); - this.on(this.EVENTS.bound, (th) => (th.bound = true)); this.on(this.EVENTS.mounted, (th) => (th.mounted = true)); - this.on(this.EVENTS.visible, (th) => (th.visible = true)); - - this.$$ojs = { - routeChanged: () => { - setTimeout(() => { - if (this.markup().length == 0) { - const h = container.resolve("h"); - if (this.isAnonymous) { - return h.deleteComponent(this.name); - } - - this.releaseMemory(); - } - }, 1000); - }, - }; /** * Compare two Nodes */ this.Reconciler = DOMReconciler; + container.resolve("repository").addComponent(this); + } + + /** + * Find a component by its UID + * @param {int|string} id + * @returns {Component|null} + */ + static findComponent(id) { + return container.resolve("repository").findComponent(id); } /** @@ -151,138 +123,21 @@ export default class Component { args = [args]; } const h = container.resolve("h"); - return h.func([this, name], ...args); - } - - /** - * Get an external Component's method - * to add it to a DOM Element - * @param {string} componentMethod `Component.method` e.g. 'MainNav.notify' - * @param {[*]} args - */ - xMethod(componentMethod, args) { - let splitted = componentMethod - .trim() - .split(/\./) - .map((a) => a.trim()); - - if (splitted.length < 2) { - console.error( - `${componentMethod} has syntax error. Please use ComponentName.methodName` - ); - } - - const h = container.resolve("h"); - let cls = h.getComponent(splitted[0]); - - if (!cls) { - console.error(`Component ${splitted[0]} not found`); - return; - } - - let obj = new cls(); - - if (!obj.method) { - console.error(`Method ${splitted[1]} not found in ${splitted[0]}`); - return; - } - - return obj.method(splitted[1], args); + return h.func({ componentId: this.id, methodName: name }, ...args); } - - /** - * Adds a Listening component - * @param {event} event - * @param {Component} component - */ - addListeningComponent(component, event) { - if (this.emitsTo(component, event)) return; - - if (!this.listening[event]) this.listening[event] = new Map(); - this.listening[event].set(component.name, component); - - component.addEmittingComponent(this, event); - } - - /** - * Adds a component that this component is listening to - * @param {string} event - * @param {Component} component - */ - addEmittingComponent(component, event) { - if (this.listensTo(component, event)) return; - - if (!this.listeningTo[component.name]) - this.listeningTo[component.name] = new Map(); - - this.listeningTo[component.name].set(event, component); - - component.addListeningComponent(this, event); - } - - /** - * Checks if this component is listening - * @param {string} event - * @param {Component} component - */ - emitsTo(component, event) { - return this.listening[event]?.has(component.name) ?? false; - } - - /** - * Checks if this component is listening to the other - * component - * @param {*} event - * @param {*} component - */ - listensTo(component, event) { - return this.listeningTo[component.name]?.has(event) ?? false; - } - - /** - * Deletes a component from the listening array - * @param {string} event - * @param {Component} component - */ - doNotListenTo(component, event) { - this.listeningTo[component.name]?.delete(event); - - if (this.listeningTo[component.name]?.size == 0) { - delete this.listeningTo[component.name]; - } - - if (!component.emitsTo(this, event)) return; - - component.doNotEmitTo(this, event); - } - - /** - * Stops this component from emitting to the other component - * @param {string} event - * @param {Component} component - * @returns - */ - doNotEmitTo(component, event) { - this.listening[event]?.delete(component.name); - - if (!component.listensTo(this, event)) return; - component.doNotListenTo(this, event); - } - /** * Get all Emitters declared in the component */ getDeclaredListeners() { if (this.__ojsRegistered) { console.warn( - `Component "${this.name}" is already registered. Skipping duplicate registration.` + `Component "component:${this.id}" is already registered. Skipping duplicate registration.` ); return; } let obj = this; let seen = new Set(); - const h = container.resolve("h"); do { if (!(obj instanceof Component)) break; @@ -299,54 +154,13 @@ export default class Component { let events = meta[0].split(/_/g); events.shift(); - let cmpName = this.name; - - let subjects = meta.slice(1); - - if (!subjects?.length) subjects = [this.name, "on"]; - - let methods = { on: true, onAll: true }; - - let stack = []; - - for (let i = 0; i < subjects.length; i++) { - let current = subjects[i]; - stack.push(current); - - while (stack.length) { - i++; - current = subjects[i] ?? null; - - if (current && methods[current]) { - stack.push(current); - } else { - stack.push("on"); - i--; - } - - let m = stack.pop(); - let cmp = stack.pop(); - - for (let j = 0; j < events.length; j++) { - let ev = events[j]; - - if (!ev.length) continue; - - h[m](cmp, ev, (component, event, ...args) => { - try { - h - .getComponent(cmpName) - [method]?.bind(h.getComponent(cmpName))( - component, - event, - ...args - ); - } catch (e) { - console.error(e); - } - }); - } - } + + for (let j = 0; j < events.length; j++) { + let ev = events[j]; + + if (!ev.length) continue; + + this.on(ev, this[method]); } seen.add(method); @@ -361,22 +175,10 @@ export default class Component { * Initializes the component and adds it to * the component map of the markup engine * @emits mounted - * @emits pre-mount */ - async mount() { - // Prevent duplicate registration - if (this.__ojsRegistered) { - console.warn( - `Component "${this.name}" is already registered. Skipping duplicate registration.` - ); - return; - } - const h = container.resolve("h"); - h.component(this.name, this); - - this.claimListeners(); - this.emit(this.EVENTS.premount); - await this.bindComponent(); + mount() { + if (this.mounted == true) return; + this.mounted = true; this.emit(this.EVENTS.mounted); } @@ -391,46 +193,11 @@ export default class Component { } this.releaseMemory(); + this.mounted = false; return true; } - /** - * Checks if this component has - * elements on the dom and if they are - * visible - */ - checkVisibility() { - const h = container.resolve("h"); - let elem = h.dom.querySelector(`ojs-${this.kebab(this.name)}`); - - if (elem && elem.parentElement?.style.display !== "none" && !this.visible) { - return this.show(); - } - - if (elem && elem.parentElement?.style.display === "none" && this.visible) { - return this.hide(); - } - - if ( - elem && - elem.style.display !== "none" && - elem.style.visibility !== "hidden" && - !this.visible - ) { - this.show(); - } - - if ( - (!elem || - elem.style.display === "none" || - elem.style.visibility === "hidden") && - this.visible - ) { - this.hide(); - } - } - /** * Emits an event * @param {string} event @@ -440,39 +207,6 @@ export default class Component { this.emitter.emit(event, this, event, ...args); } - /** - * Binds this component to the elements on the dom. - * @emits pre-bind - * @emits markup-bound - * @emits bound - */ - async bindComponent() { - this.emit(this.EVENTS.prebind); - const h = container.resolve("h"); - let all = h.dom.querySelectorAll(`ojs-${this.kebab(this.name)}-tmp--`); - - if (all.length == 0 && !this.bindCalled) { - this.bindCalled = true; - setTimeout(this.bindComponent.bind(this), 500); - return; - } - - for (let elem of all) { - let hId = elem.getAttribute("ojs-key"); - - let args = [...h.compArgs.get(hId)]; - h.compArgs.delete(hId); - - this.wrap(...args, { parent: elem, replaceParent: true }); - - this.emit(this.EVENTS.markupBound, [elem, args]); - } - - this.emit(this.EVENTS.bound); - - return true; - } - /** * Converts camel case to kebab case * @param {string} name @@ -498,62 +232,9 @@ export default class Component { const h = container.resolve("h"); if (!parent) parent = h.dom; - return parent.querySelectorAll(`ojs-${this.kebab(this.name)}`); - } - - /** - * Hides all the markup of this component - * @emits before-hidden - * @emits hidden - * @returns {bool} - */ - hide() { - this.emit(this.EVENTS.beforeHidden); - - let all = this.markup(); - - for (let elem of all) { - elem.style.display = "none"; - } - - this.emit(this.EVENTS.hidden); - - return true; - } - - /** - * Remove style-display-none from all this component's markup - * @emits before-visible - * @emits visible - * @returns bool - */ - show() { - this.emit(this.EVENTS.beforeVisible); - - let all = this.markup(); - - for (let elem of all) { - elem.style.display = ""; - } - - this.emit(this.EVENTS.visible); - - return true; - } - - /** - * Ensure that the action will get called - * even if the event was emitted previously - * @param {string} event - * @param {...function} listeners - */ - onAll(event, ...listeners) { - // check if we have previously emitted this event - listeners.forEach((a) => { - if (event in this.emitter.emitted) a(...this.emitter.emitted[event]); - - this.emitter.on(event, a); - }); + return parent.querySelectorAll( + `ojs-${this.kebab(this.name)}[uid="${this.id}"]` + ); } /** @@ -562,7 +243,6 @@ export default class Component { * @param {...function} listeners */ on(event, ...listeners) { - // check if we have previously emitted this event listeners.forEach((a) => { if (Array.isArray(a)) { a.forEach((f) => this.emitter.on(event, f)); @@ -573,48 +253,26 @@ export default class Component { }); } - /** - * Gets all the listeners for itself and adds them to itself - */ - claimListeners() { - const h = container.resolve("h"); - if (!h.eventsMap.has(this.name)) return; - - let events = h.eventsMap.get(this.name); - - for (let event in events) { - events[event].forEach((listener) => { - let func = listener.function; - - if (listener.type === "all") this.onAll(event, func); - else this.on(event, func); - }); - } - - h.eventsMap.delete(this.name); - } - releaseMemory() { + container.resolve("repository").removeComponent(this.id); this.cleanUp(); - for (let event in this.listening) { - for (let [_name, component] of this.listening[event]) { - component.doNotListenTo(this, event); - } - } + this.emitter.clear(); + this.argsMap.clear(); for (let id in this.states) { - this.states[id]?.off(this.name); + this.states[id]?.off(`component-${this.id}`); delete this.states[id]; } - this.argsMap = new Map(); - this.listeningTo = {}; - this.listening = {}; + const broker = container.resolve("broker"); + + for (let event in this.__brokerEvents__) { + for (let listener of this.__brokerEvents__[event]) { + broker.off(event, listener); + } - if (this.isAnonymous) { - this.emitter.listeners = {}; - this.emitter.emitted = {}; + delete this.__brokerEvents__[event]; } } @@ -650,7 +308,7 @@ export default class Component { typeof args[i].$__name__ !== "undefined" && args[i].$__name__ == "OpenScript.State") ) { - args[i].listener(this); + args[i].listener(`component-${this.id}`); this.states[args[i].$__id__] = args[i]; final.states.push(args[i].$__id__); } else if ( @@ -695,7 +353,7 @@ export default class Component { /** * Wraps the rendered content - * @emits re-rendered + * @emits rerendered * @param {...any} args * @returns */ @@ -707,29 +365,26 @@ export default class Component { // check if the render was called due to a state change if (lastArg && lastArg["called-by-state-change"]) { - let state = lastArg.self; + let stateId = lastArg.stateId; delete args[index]; let current = h.dom.querySelectorAll( - `ojs-${this.kebab(this.name)}[s-${state.$__id__}="${state.$__id__}"]` + `ojs-${this.kebab(this.name)}[uid="${this.id}"][s-${stateId}="${ + stateId + }"]` ) ?? []; let reconciler = new this.Reconciler(); current.forEach((e) => { - if (!this.visible) e.style.display = "none"; - else e.style.display = ""; + let arg = this.argsMap.get(Number(e.getAttribute("uid"))); - // e.textContent = ""; - - let arg = this.argsMap.get(e.getAttribute("uuid")); let attr = { - // parent: e, - component: this, + componentId: this.id, event: this.EVENTS.rerendered, - eventParams: [{ markup: e, component: this }], + eventParams: [{ componentId: this.id }], }; let shouldReconcile = true; @@ -764,19 +419,18 @@ export default class Component { if (!this.markup().length) this.argsMap.clear(); else { let all = this.markup(parent); - - all.forEach((elem) => this.argsMap.delete(elem.getAttribute("uuid"))); + all.forEach((elem) => this.argsMap.delete(elem.getAttribute("uid"))); } if (this.argsMap.size) event = this.EVENTS.rerendered; } - let uuid = `${Component.uid++}-${new Date().getTime()}`; + let uuid = this.id; this.argsMap.set(uuid, args ?? []); let attr = { - uuid, + uid: uuid, resetParent, replaceParent, firstOfParent, @@ -808,9 +462,9 @@ export default class Component { attr = { ...attr, - component: this, + componentId: this.id, event, - eventParams: [{ markup, component: this }], + eventParams: [{ componentId: this.id }], }; return h[`ojs-${this.kebab(this.name)}`](attr, markup, cAttributes); @@ -847,11 +501,10 @@ export default class Component { } }; - let c = new Cls(); - c.getDeclaredListeners(); - c.mount(); + let h = container.resolve("h"); + h.registerComponent(`anonym-${id}`, Cls); - return c.name; + return `anonym-${id}`; } /** diff --git a/src/component/MarkupEngine.js b/src/component/MarkupEngine.js index ecaf7ec..a86e9ac 100644 --- a/src/component/MarkupEngine.js +++ b/src/component/MarkupEngine.js @@ -3,6 +3,7 @@ import Utils from "../utils/Utils.js"; import Component from "./Component.js"; import State from "../core/State.js"; import { container } from "../core/Container.js"; +import { indirectEventHandler, isClass } from "../utils/helpers.js"; /** * Base Markup Engine Class @@ -14,664 +15,560 @@ export default class MarkupEngine { */ static ID = 0; - constructor() { - /** - * Keeps the components - * @type {Map} - */ - this.compMap = new Map(); - - /** - * Keeps the components arguments - * @type {Map} - */ - this.compArgs = new Map(); - - /** - * Keeps a temporary component-events map - * @type {Map>} - */ - this.eventsMap = new Map(); + /** + * Keeps the components + * @type {Map} + */ + compMap = new Map(); + reconciler = new DOMReconciler(); - this.reconciler = new DOMReconciler(); + /** + * References the DOM object + */ + dom = window.document; - /** - * References the DOM object - */ - this.dom = window.document; + constructor() {} - /** - * - * @param {string} name component name - * @param {class} component OpenScript component class. - */ - this.component = (name, component) => { - if (!(typeof name === "string")) { - throw Error( - `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` - ); - } + /** + * + * @param {string} name component name + * @param {class} componentClass OpenScript component class. + */ + registerComponent = (name, componentClass) => { + if (!(typeof name === "string")) { + throw Error( + `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` + ); + } - if (!(component.prototype instanceof Component)) { - throw new Error( - `MarkupEngine.Exception: The component for ${name} must be an Component component. ${component.name} given` - ); - } + if (!(componentClass.prototype instanceof Component)) { + throw new Error( + `MarkupEngine.Exception: The component for ${name} must be an Component component. ${componentClass.name} given` + ); + } - this.compMap.set(name, component); - }; + this.compMap.set(name, componentClass); + }; - /** - * Deletes the component from the Markup Engine Map. - * @emits unmount - * Removes an already registered company - * @param {string} name - * @param {boolean} withMarkup remove the markup of this component - * as well. - * @returns {boolean} - */ - this.deleteComponent = (name, withMarkup = true) => { - if (!this.has(name)) { - return false; - } + /** + * Deletes the component from the Markup Engine Map. + * @param {string} name + * @returns {boolean} + */ + deleteComponent = (name) => { + if (!this.hasComponent(name)) { + return false; + } - if (withMarkup) this.getComponent(name).unmount(); + return this.compMap.delete(name); + }; - this.getComponent(name).emit("unmount"); + /** + * Checks if a component is registered with the + * markup engine. + * @param {string} name + * @returns + */ + hasComponent = (name) => { + return this.compMap.has(name); + }; - return this.compMap.delete(name); - }; + /** + * Checks if a component is registered + * @param {string} name + * @param {string} method method name + * @returns + */ + isRegistered = (name, method = "access") => { + if (this.hasComponent(name)) return true; - /** - * Checks if a component is registered with the - * markup engine. - * @param {string} name - * @returns - */ - this.has = (name) => { - return this.compMap.has(name); - }; + console.warn( + `MarkupEngine.Warn: Trying to ${method} an unregistered component {${name}}. Please ensure that the component is registered by using h.has(componentName)` + ); - /** - * Checks if a component is registered - * @param {string} name - * @param {string} method method name - * @returns - */ - this.isRegistered = (name, method = "access") => { - if (this.has(name)) return true; + return false; + }; - console.warn( - `MarkupEngine.Warn: Trying to ${method} an unregistered component {${name}}. Please ensure that the component is registered by using h.has(componentName)` - ); + reconcile = (domNode, newNode) => { + this.reconciler.reconcile(newNode, domNode); + }; - return false; - }; + modify = (element) => { + element.__eventListeners ??= new Set(); - this.reconcile = (domNode, newNode) => { - this.reconciler.reconcile(newNode, domNode); + element.addListener = function (event, listener) { + this.__eventListeners.add(event); + this.addEventListener(event, listener); }; - /** - * Removes all a component's markup - * from the DOM - * @param {string} name - */ - this.hide = (name) => { - if (!this.isRegistered(name, "hide")) return false; - - const c = this.getComponent(name); - c.hide(); + element.removeListener = function (event, listener) { + // remove the listener from the event map in the repository + // since for each event, there is only the indirect event handler + // listening to that event. + let eventMap = container.resolve("repository").domListeners.get(this); - return true; - }; + let listeners = eventMap.get(event); - /** - * make all the component visible - * @param {string} name component name - * @returns - */ - this.show = (name) => { - if (!this.isRegistered(name, "show")) return false; + if (!listeners) return; - const c = this.getComponent(name); - c.show(); + for (let l of listeners) { + if (l === listener) { + listeners.delete(l); + break; + } + } - return true; + if (listeners.size === 0) { + eventMap.delete(event); + this.__eventListeners.delete(event); + } }; - this.modify = (element) => { - element.__eventListeners = element.__eventListeners ?? {}; - - element.addListener = function (event, listener) { - this.__eventListeners[event] = this.__eventListeners[event] ?? []; - this.__eventListeners[event].push(listener); - this.addEventListener(event, listener); - }; + element.getEventListeners = function () { + return container.resolve("repository").domListeners.get(this); + }; - element.removeListener = function (event, listener) { - this.__eventListeners[event] = this.__eventListeners[event]?.filter( - (x) => x !== listener - ); + element.toString = function () { + return this.outerHTML; + }; - this.removeEventListener(event, listener); - }; + element.methods = function () { + let methods = {}; - element.getEventListeners = function () { - return this.__eventListeners; - }; + // get the methods from the repository + let methodsMap = container.resolve("repository").domMethods.get(this); - if (!element.__methods) { - element.__methods = {}; + for (let [k, v] of methodsMap) { + methods[k] = v; } - element.methods = function () { - let methods = {}; - - for (let m in this.__methods) { - methods[m] = this.__methods[m].bind(this); - } - - return methods; - }; + return methods; }; - this.fromString = (string, outerElement = "div", ...args) => { - const h = container.resolve("h"); - let elem = h[outerElement](...args); - elem.innerHTML = string; - return elem; + element.__openscript_cleanup__ = () => { + delete element.addListener; + delete element.removeListener; + delete element.getEventListeners; + delete element.methods; + delete element.__eventListeners; + delete element.__methods; + delete element.toString; + delete element.__openscript_cleanup__; }; + }; - /** - * handles the DOM element creation - * @param {string} name - * @param {...any} args - */ - this.handle = (name, ...args) => { - if (!(typeof name === "string")) { - throw Error( - `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` - ); - } + fromString = (string, outerElement = "div", ...args) => { + const h = container.resolve("h"); + let elem = h[outerElement](...args); + elem.innerHTML = string; + return elem; + }; - if (/^[_\$]+$/.test(name)) { - name = Component.FRAGMENT.toLowerCase(); - } + /** + * handles the DOM element creation + * @param {string} name + * @param {...any} args + */ + handle = (name, ...args) => { + if (!(typeof name === "string")) { + throw Error( + `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` + ); + } - let isSvg = false; + if (/^[_\$]+$/.test(name)) { + name = Component.FRAGMENT.toLowerCase(); + } - if (/^\$\w+$/.test(name)) { - name = name.substring(1); - isSvg = true; - } + let isSvg = false; - /** - * If this is a component, return it - */ + if (/^\$\w+$/.test(name)) { + name = name.substring(1); + isSvg = true; + } - if (this.compMap.has(name)) { - return this.compMap.get(name).wrap(...args); - } + /** + * If this is a component, return it + */ + if (this.hasComponent(name)) { + let cls = this.getComponent(name); + let cmp = null; - let component; - let event = ""; - let eventParams = []; - - const isComponentName = (tag) => { - return /^ojs-.*$/.test(tag); - }; - - /** - * - * @param {string} tag - */ - const getComponentName = (tag) => { - let name = tag - .toLowerCase() - .replace(/^ojs-/, "") - .replace(/-tmp--$/, ""); - - return Utils.camel(name, true); - }; - - /** - * @type {DocumentFragment|HTMLElement} - */ - let parent = null; - - let emptyParent = false; - let replaceParent = false; - let prependToParent = false; - let rootFrag = new DocumentFragment(); - - const isUpperCase = (string) => /^[A-Z]*$/.test(string); - let isComponent = isUpperCase(name[0]); - - /** - * @type {HTMLElement} - */ - let root = null; - - let componentAttribute = {}; - let withCAttr = false; - - /** - * When dealing with a component - * save the argument for async rendering - */ - if (isComponent) { - root = this.dom.createElement(`ojs-${Utils.kebab(name)}-tmp--`); - - let id = `ojs-${Utils.kebab(name)}-${MarkupEngine.ID++}`; - - root.setAttribute("ojs-key", id); - root.setAttribute("class", "__ojs-c-class__"); - - this.compArgs.set(id, args); + if (!isClass(cls)) { + cmp = new Component(cls.name); + cmp.render = cls.bind(cmp); } else { - root = isSvg - ? this.dom.createElementNS("http://www.w3.org/2000/svg", name) - : this.dom.createElement(name); + cmp = new cls(); } + cmp.getDeclaredListeners(); - this.modify(root); - - let parseAttr = (obj) => { - for (let k in obj) { - let v = obj[k]; + return cmp.wrap(...args); + } - if (v instanceof State) { - v = v.value; - } - - if (k === "parent" && v instanceof HTMLElement) { - parent = v; - continue; - } - - if (k === "resetParent" && typeof v === "boolean") { - emptyParent = v; - continue; - } - - if (k === "firstOfParent" && typeof v === "boolean") { - prependToParent = v; - continue; - } + let component; + let event = ""; + let eventParams = []; - if (k === "event" && typeof v === "string") { - event = v; - continue; - } - - if (k === "replaceParent" && typeof v === "boolean") { - replaceParent = v; - continue; - } - - if (k === "eventParams") { - if (!Array.isArray(v)) v = [v]; - eventParams = v; - continue; - } + const isComponentName = (tag) => { + return /^ojs-.*$/.test(tag); + }; - if (k === "component" && v instanceof Component) { - component = v; - continue; - } + /** + * @type {DocumentFragment|HTMLElement} + */ + let parent = null; - if (k === "c_attr") { - componentAttribute = v; - continue; - } + let emptyParent = false; + let replaceParent = false; + let prependToParent = false; + let rootFrag = new DocumentFragment(); - if (k.length && k[0] === "$") { - componentAttribute[k.substring(1)] = v; - continue; - } + const isUpperCase = (string) => /^[A-Z]*$/.test(string); + let isComponent = isUpperCase(name[0]); - if (k === "withCAttr") { - withCAttr = true; - continue; - } + let componentAttribute = {}; + let withCAttr = false; - if (k === "listeners") { - if (typeof v !== "object") { - throw TypeError( - `The value of 'listeners' should be an object. but found ${typeof v}` - ); - } - - for (let evt in v) { - let listener = v[evt]; + /** + * @type {HTMLElement|SVGElement} + */ + let root = isSvg + ? this.dom.createElementNS("http://www.w3.org/2000/svg", name) + : this.dom.createElement(name); - if (Array.isArray(listener)) { - listener.forEach((l) => root.addListener(evt, l)); - } else { - root.addListener(evt, listener); - } - } + this.modify(root); - continue; - } + let parseAttr = (obj) => { + for (let k in obj) { + let v = obj[k]; - if (k === "methods") { - if (typeof v !== "object") { - throw TypeError( - `The value of 'methods' attribute should be an object. but found ${typeof v}` - ); - } + if (v instanceof State) { + v = v.value; + } - for (let method in v) { - let func = v[method]; - root.__methods[method] = func; - } + if (k === "parent" && v instanceof HTMLElement) { + parent = v; + continue; + } - continue; - } + if (k === "resetParent" && typeof v === "boolean") { + emptyParent = v; + continue; + } - let val = `${v}`; - if (Array.isArray(v)) val = `${v.join(" ")}`; + if (k === "firstOfParent" && typeof v === "boolean") { + prependToParent = v; + continue; + } - k = k.replace(/_/g, "-"); + if (k === "event" && typeof v === "string") { + event = v; + continue; + } - if (k === "class" || k === "Class") { - let cls = root.getAttribute(k) ?? ""; - val = cls + (cls.length > 0 ? " " : "") + `${val}`; - } + if (k === "replaceParent" && typeof v === "boolean") { + replaceParent = v; + continue; + } - try { - root.setAttribute(k, val); - } catch (e) { - console.error( - `MarkupEngine.ParseAttribute.Exception: `, - e, - `. Attributes resulting in the error: `, - obj - ); - throw Error(e); - } + if (k === "eventParams") { + if (!Array.isArray(v)) v = [v]; + eventParams = v; + continue; } - }; - const parse = (arg, isComp) => { if ( - arg instanceof DocumentFragment || - arg instanceof HTMLElement || - arg instanceof SVGElement || - arg instanceof State + k === "componentId" && + (typeof v === "string" || typeof v === "number") ) { - if (isComp) return true; - - if (arg instanceof State) { - typeof arg.value === "string" && - rootFrag.append(document.createTextNode(arg)); - } else { - rootFrag.append(arg); - } - - return true; + component = Component.findComponent(v); + continue; } - if (typeof arg === "object") { - parseAttr(arg); - return true; + if (k === "c_attr") { + componentAttribute = v; + continue; } - if (typeof arg !== "undefined") { - rootFrag.append(arg); - return true; + if (k.length && k[0] === "$") { + componentAttribute[k.substring(1)] = v; + continue; } - return false; - }; + if (k === "withCAttr") { + withCAttr = true; + continue; + } - for (let arg of args) { - if (isComponent && parent) break; + if (k === "listeners") { + if (typeof v !== "object") { + throw TypeError( + `The value of 'listeners' should be an object. but found ${typeof v}` + ); + } - // if (arg instanceof State) continue; + for (let evt in v) { + let listener = v[evt]; - if ( - Array.isArray(arg) || - arg instanceof HTMLCollection || - arg instanceof NodeList - ) { - if (isComponent) continue; + if (Array.isArray(listener)) { + listener.forEach((l) => { + this.registerDomListeners(root, evt, l); + }); - arg.forEach((e) => { - if (e) parse(e, isComponent); - }); + root.addListener(evt, indirectEventHandler); + } else { + this.registerDomListeners(root, evt, listener); + root.addListener(evt, indirectEventHandler); + } + } continue; } - if (parse(arg, isComponent)) continue; + if (k === "methods") { + if (typeof v !== "object") { + throw TypeError( + `The value of 'methods' attribute should be an object. but found ${typeof v}` + ); + } + + let methodMap = container.resolve("repository").domMethods.get(root); + if (!methodMap) { + methodMap = new Map(); + container.resolve("repository").domMethods.set(root, methodMap); + } - if (isComponent) continue; + for (const name in v) { + const fn = v[name]; + methodMap.set(name, fn); + defineDomMethod(root, name); + } - let v = this.toElement(arg); - if (typeof v !== "undefined") rootFrag.append(v); - } + continue; + } - root.append(rootFrag); + let val = `${v}`; + if (Array.isArray(v)) val = `${v.join(" ")}`; - if (withCAttr) { - let atr = JSON.stringify(componentAttribute); - if (atr) root.setAttribute("c-attr", atr); - } + k = k.replace(/_/g, "-"); - root.toString = function () { - return this.outerHTML; - }; + if (k === "class" || k === "Class") { + let cls = root.getAttribute(k) ?? ""; + val = cls + (cls.length > 0 ? " " : "") + `${val}`; + } - if (parent) { - if (emptyParent) { - parent.textContent = ""; + try { + root.setAttribute(k, val); + } catch (e) { + console.error( + `MarkupEngine.ParseAttribute.Exception: `, + e, + `. Attributes resulting in the error: `, + obj + ); + throw Error(e); } + } + }; - if (replaceParent) { - this.reconcile(parent, root); - } else if (prependToParent) { - parent.prepend(root); + const parse = (arg, isComp) => { + if ( + arg instanceof DocumentFragment || + arg instanceof HTMLElement || + arg instanceof SVGElement || + arg instanceof State + ) { + if (isComp) return true; + + if (arg instanceof State) { + typeof arg.value === "string" && + rootFrag.append(document.createTextNode(arg)); } else { - parent.append(root); + rootFrag.append(arg); } + + return true; } - if (component) { - component.emit(event, eventParams); + if (typeof arg === "object") { + parseAttr(arg); + return true; + } - let sc = root.querySelectorAll(".__ojs-c-class__"); - sc.forEach((c) => { - if (!isComponentName(c.tagName.toLowerCase())) return; - let cmpName = getComponentName(c.tagName); - const h = container.resolve("h"); - h.getComponent(cmpName)?.emit(event, eventParams); - }); + if (typeof arg !== "undefined") { + rootFrag.append(arg); + return true; } - return root; + return false; }; - /** - * Executes a function that returns an - * HTMLElement and adds that element to the overall markup. - * @param {function} f - This function should return an HTMLElement or a string or an Array of either - * @returns {HTMLElement|string|Array} - */ - this.call = ( - f = () => { - const h = container.resolve("h"); - return h["ojs-group"](); - } - ) => { - return f(); - }; + for (let arg of args) { + if (isComponent && parent) break; - /** - * Allows you to add functions to HTML elements - * @param {Array} ComponentAndMethod name of the method - * @param {...any} args arguments to pass to the method - * @returns - */ - this.func = (name, ...args) => { - let method = null; - let component = null; + if ( + Array.isArray(arg) || + arg instanceof HTMLCollection || + arg instanceof NodeList + ) { + if (isComponent) continue; + + for (let e of arg) { + if (e) parse(e, isComponent); + } - if (!Array.isArray(name)) { - method = name; - return `${method}(${this._escape(args)})`; + continue; } - method = name[1]; - component = name[0]; + if (parse(arg, isComponent)) continue; - return `component('${component.name}')['${method}'](${this._escape( - args - )})`; - }; + if (isComponent) continue; - /** - * - * adds quotes to string arguments - * and serializes objects for - * param passing - * @note To escape adding quotes use ${string} - */ - this._escape = (args) => { - let final = []; - - for (let e of args) { - if (typeof e === "number") final.push(e); - else if (typeof e === "boolean") final.push(e); - else if (typeof e === "string") { - if (e.length && e.substring(0, 2) === "${") { - let length = e[e.length - 1] === "}" ? e.length - 1 : e.length; - final.push(e.substring(2, length)); - } else final.push(`'${e}'`); - } else if (typeof e === "object") final.push(JSON.stringify(e)); - } + let v = this.toElement(arg); + if (typeof v !== "undefined") rootFrag.append(v); + } - return final; - }; + root.append(rootFrag); - this.__addToEventsMap = (component, event, listeners) => { - if (!this.eventsMap.has(component)) { - this.eventsMap.set(component, {}); - this.eventsMap.get(component)[event] = listeners; - return; - } + if (withCAttr) { + let atr = JSON.stringify(componentAttribute); + if (atr) root.setAttribute("c-attr", atr); + } - if (!this.eventsMap.get(component)[event]) { - this.eventsMap.get(component)[event] = []; + if (parent) { + if (emptyParent) { + parent.textContent = ""; } - this.eventsMap.get(component)[event].push(...listeners); - }; + if (replaceParent) { + this.reconcile(parent, root); + } else if (prependToParent) { + parent.prepend(root); + } else { + parent.append(root); + } + } - /** - * Adds an event listener to a component - * @param {string|Array} component component name - * @param {string} event event name - * @param {...function} listeners listeners - */ - this.on = (component, event, ...listeners) => { - let components = component; + if (component) { + component.emit(event, eventParams); + let sc = root.querySelectorAll(".__ojs-c-class__"); - if (!Array.isArray(component)) components = [component]; + sc.forEach((c) => { + if (!isComponentName(c.tagName.toLowerCase())) return; + let cmp = Component.findComponent(Number(c.getAttribute("uid"))); - for (let component of components) { - if (/\./.test(component)) { - let tmp = component.split(".").filter((e) => e); - component = tmp[0]; - listeners.push(event); - event = tmp[1]; - } + if (!cmp) return; - if (this.has(component)) { - this.getComponent(component).on(event, ...listeners); + cmp?.emit(event, eventParams); - continue; + if (parent.isConnected) { + cmp.mount(); } + }); + } - listeners.forEach((f, i) => { - listeners[i] = { type: "after", function: f }; - }); + return root; + }; - this.__addToEventsMap(component, event, listeners); - } - }; - - /** - * Add events listeners to a component that will - * execute even after the event has been emitted - * @param {string|Array} component - * @param {string} event - * @param {...function} listeners - */ - this.onAll = (component, event, ...listeners) => { - let components = component; - - if (!Array.isArray(component)) components = [component]; + /** + * Executes a function that returns an + * HTMLElement and adds that element to the overall markup. + * @param {function} f - This function should return an HTMLElement or a string or an Array of either + * @returns {HTMLElement|string|Array} + */ + call = ( + f = () => { + const h = container.resolve("h"); + return h["ojs-group"](); + } + ) => { + return f(); + }; - for (let component of components) { - if (/\./.test(component)) { - let tmp = component.split(".").filter((e) => e); - component = tmp[0]; - listeners.push(event); - event = tmp[1]; - } + /** + * Allows you to add functions to HTML elements + * @param {Array} ComponentAndMethod name of the method + * @param {...any} args arguments to pass to the method + * @returns + */ + func = (name, ...args) => { + let method = null; + let componentId = null; - if (this.has(component)) { - this.getComponent(component).onAll(event, ...listeners); - continue; - } + if (!(typeof name === "object")) { + method = name; + return `${method}(${this._escape(args)})`; + } - listeners.forEach((f, i) => { - listeners[i] = { type: "all", function: f }; - }); + method = name.methodName; + componentId = name.componentId; - this.__addToEventsMap(component, event, listeners); - } - }; + return `component('${componentId}')['${method}'](${this._escape(args)})`; + }; - /** - * Gets the event emitter of a component - * @param {string} component component name - * @returns - */ - this.emitter = (component) => { - return this.compMap.get(component)?.emitter; - }; + /** + * + * adds quotes to string arguments + * and serializes objects for + * param passing + * @note To escape adding quotes use ${string} + */ + _escape = (args) => { + let final = []; + + for (let e of args) { + if (typeof e === "number") final.push(e); + else if (typeof e === "boolean") final.push(e); + else if (typeof e === "string") { + if (e.length && e.substring(0, 2) === "${") { + let length = e[e.length - 1] === "}" ? e.length - 1 : e.length; + final.push(e.substring(2, length)); + } else final.push(`'${e}'`); + } else if (typeof e === "object") final.push(JSON.stringify(e)); + } + + return final; + }; - /** - * Gets a component and returns it - * @param {string} name - * @returns {Component|null} - */ - this.getComponent = (name) => { - return this.compMap.get(name); - }; + /** + * Gets a component and returns it + * @param {string} name + * @returns {class-string} + */ + getComponent = (name) => { + return this.compMap.get(name); + }; - /** - * Creates an anonymous component - * around a state - * @param {State} state - * @param {Array} attribute attribute path - * @returns - */ - this.$anonymous = (state, callback = (state) => state.value, ...args) => { - const h = container.resolve("h"); - return h[Component.anonymous()](state, callback, ...args); - }; + /** + * Creates an anonymous component + * around a state + * @param {State} state + * @param {Array} attribute attribute path + * @returns + */ + $anonymous = (state, callback = (state) => state.value, ...args) => { + const h = container.resolve("h"); + return h[Component.anonymous()](state, callback, ...args); + }; - /** - * Converts a value to HTML element; - * @param {string|HTMLElement} value - */ - this.toElement = (value) => { - return value; - }; - } + /** + * Converts a value to HTML element; + * @param {string|HTMLElement} value + */ + toElement = (value) => { + return value; + }; + + registerDomListeners = (node, event, listener) => { + let eventMap = container.resolve("repository").domListeners.get(node); + + if (!eventMap) { + eventMap = new Map(); + container.resolve("repository").domListeners.set(node, eventMap); + } + + let listeners = eventMap.get(event) ?? []; + listeners.push(listener); + eventMap.set(event, listeners); + }; } diff --git a/src/core/Emitter.js b/src/core/Emitter.js index 9eb87d4..c5700fc 100644 --- a/src/core/Emitter.js +++ b/src/core/Emitter.js @@ -2,77 +2,83 @@ * Event Emitter Class */ export default class Emitter { - constructor() { - this.listeners = {}; - /** - * List of emitted events - */ - this.emitted = {}; - } + constructor() { + this.listeners = new Map(); + } - addListener(eventName, fn) { - this.listeners[eventName] = this.listeners[eventName] || []; - this.listeners[eventName].push(fn); - return this; - } - // Attach event listener - on(eventName, fn) { - return this.addListener(eventName, fn); + addListener(eventName, fn) { + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, []); } + this.listeners.get(eventName).push(fn); + return this; + } + // Attach event listener + on(eventName, fn) { + return this.addListener(eventName, fn); + } - // Attach event handler only once. Automatically removed. - once(eventName, fn) { - this.listeners[eventName] = this.listeners[eventName] || []; - const onceWrapper = (...args) => { - fn(...args); - this.off(eventName, onceWrapper); - }; - this.listeners[eventName].push(onceWrapper); - return this; + // Attach event handler only once. Automatically removed. + once(eventName, fn) { + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, []); } - // Alias for removeListener - off(eventName, fn) { - return this.removeListener(eventName, fn); - } + const onceWrapper = (...args) => { + fn(...args); + this.off(eventName, onceWrapper); + }; + this.listeners.get(eventName).push(onceWrapper); + return this; + } + + // Alias for removeListener + off(eventName, fn) { + return this.removeListener(eventName, fn); + } - removeListener(eventName, fn) { - let lis = this.listeners[eventName]; - if (!lis) return this; - for (let i = lis.length; i > 0; i--) { - if (lis[i] === fn) { - lis.splice(i, 1); - break; - } - } - return this; + removeListener(eventName, fn) { + let lis = this.listeners.get(eventName); + if (!lis) return this; + for (let i = lis.length - 1; i >= 0; i--) { + if (lis[i] === fn) { + lis.splice(i, 1); + break; // Found and removed, break loop + } } + return this; + } - // Fire the event - emit(eventName, ...args) { - this.emitted[eventName] = args; + // Fire the event + emit(eventName, ...args) { + let fns = this.listeners.get(eventName); + if (!fns) return false; + fns.forEach((f) => { + try { + f(...args); + } catch (e) { + console.error(e); + } + }); + return true; + } - let fns = this.listeners[eventName]; - if (!fns) return false; - fns.forEach((f) => { - try { - f(...args); - } catch (e) { - console.error(e); - } - }); - return true; - } + listenerCount(eventName) { + let fns = this.listeners.get(eventName) || []; + return fns.length; + } - listenerCount(eventName) { - let fns = this.listeners[eventName] || []; - return fns.length; - } + // Get raw listeners + // If the once() event has been fired, then that will not be part of + // the return array + rawListeners(eventName) { + return this.listeners.get(eventName); + } - // Get raw listeners - // If the once() event has been fired, then that will not be part of - // the return array - rawListeners(eventName) { - return this.listeners[eventName]; - } + /** + * Clear all listeners + */ + clear() { + this.listeners.clear(); + } } diff --git a/src/core/Repository.js b/src/core/Repository.js new file mode 100644 index 0000000..09024ff --- /dev/null +++ b/src/core/Repository.js @@ -0,0 +1,121 @@ +import State from "./State"; + +/** + * Repository to manage object references and prevent memory leaks. + * This class provides a centralized location to track Components, States, and Mediators. + */ +export default class Repository { + /** + * Initialize the Repository + */ + constructor() { + /** + * Map of Component references (Strong references for lookup by ID). + * CAUTION: Objects in this map must be manually removed to prevent memory leaks. + * @type {Map} + */ + this.components = new Map(); + + /** + * Map of State references. + * @type {Map} + */ + this.states = new Map(); + + /** + * Map of Mediator references. + * @type {Map} + */ + this.mediators = new Map(); + + this.domListeners = new WeakMap(); + this.domMethods = new WeakMap(); + } + + /** + * Add a component to the repository + * @param {Component} component + */ + addComponent(component) { + if (component && component.id) { + this.components.set(component.id, component); + } + } + + /** + * Find a component by its ID + * @param {string|number} id + * @returns {Component|undefined} + */ + findComponent(id) { + return this.components.get(Number(id)); + } + + /** + * Remove a component from the repository (Strong Reference) + * @param {Component|string|number} componentOrId + */ + removeComponent(componentOrId) { + let id = componentOrId; + if (typeof componentOrId === "object" && componentOrId.id) { + id = componentOrId.id; + } + this.components.delete(Number(id)); + } + + /** + * Add a state to the repository + * @param {State} state + */ + addState(state) { + if (state && state.$__id__) { + this.states.set(state.$__id__, state); + } + } + + /** + * Find a state by its ID + * @param {string|number} id + * @returns {State|undefined} + */ + findState(id) { + return this.states.get(Number(id)); + } + + /** + * Remove a state from the repository (Strong Reference) + * @param {State|string|number} stateOrId + */ + removeState(stateOrId) { + let id = stateOrId; + if (typeof stateOrId === "object" && stateOrId.$__id__) { + id = stateOrId.$__id__; + } + this.states.delete(Number(id)); + } + + /** + * Add a mediator to the repository + * @param {Mediator} mediator + */ + addMediator(mediator) { + this.mediators.set(mediator.id, mediator); + } + + /** + * Find a mediator by its ID + * @param {string} id + * @returns {Mediator|undefined} + */ + findMediator(id) { + return this.mediators.get(Number(id)); + } + + /** + * Remove a mediator from the repository + * @param {string} id + */ + removeMediator(id) { + this.mediators.delete(Number(id)); + } +} diff --git a/src/core/Runner.js b/src/core/Runner.js index 5441904..25ae4aa 100644 --- a/src/core/Runner.js +++ b/src/core/Runner.js @@ -29,6 +29,7 @@ export default class Runner { ); const registrations = container.resolve("__ojs_registrations"); + const h = container.resolve("h"); for (let i = 0; i < cls.length; i++) { let c = cls[i]; @@ -36,9 +37,7 @@ export default class Runner { const classKey = this.getClassKey(c); if (!this.isClass(c)) { - // Functional component - always create new instance (not a singleton) instance = new Component(c.name); - instance.render = c.bind(instance); } else { if (registrations[classKey] === "ongoing") { continue; @@ -57,15 +56,12 @@ export default class Runner { } if (instance instanceof Component) { - instance.getDeclaredListeners(); - await instance.mount(); - instance.__ojsRegistered = true; registrations[classKey] = "completed"; + h.registerComponent(c.name, c); } else if (instance instanceof Mediator || instance instanceof Listener) { await instance.register(); registrations[classKey] = "completed"; } else if (instance instanceof Context) { - // Context instances don't need registration registrations[classKey] = "completed"; } else { throw Error( diff --git a/src/core/State.js b/src/core/State.js index f4e2322..8cf65e3 100644 --- a/src/core/State.js +++ b/src/core/State.js @@ -1,246 +1,260 @@ +import { container } from "./Container.js"; import ProxyFactory from "./ProxyFactory.js"; /** * The main State class */ export default class State { + /** + * The count of the number of states in the program + */ + static count = 0; + + static VALUE_CHANGED = "value-changed"; + + constructor() { /** - * The count of the number of states in the program + * The value of the state */ - static count = 0; + this.value; - static VALUE_CHANGED = "value-changed"; + /** + * ID of this state + */ + this.$__id__; - constructor() { - /** - * The value of the state - */ - this.value; + /** + * Has this state changed + */ + this.$__changed__ = false; - /** - * ID of this state - */ - this.$__id__; + this.$__name__ = "OpenScript.State"; - /** - * Has this state changed - */ - this.$__changed__ = false; + this.$__CALLBACK_ID__ = 0; - this.$__name__ = "OpenScript.State"; + /** + * Tells the component to rerender + */ + this.$__signature__ = { + "called-by-state-change": true, + stateId: this.$__id__, + }; + + this.$__listeners__ = new Map(); + } + + /** + * Add a component that listens to this state + * @param {Component|Function|string} listener + * @returns + */ + listener(listener) { + // Assuming Component check via duck typing or name if circular dependency prevents instanceof + if ( + listener && + ((typeof listener === "string" && /^component-\d+$/.test(listener)) || + (listener.id && typeof listener.wrap === "function")) + ) { + if (typeof listener === "string") { + let uid = listener.split("-")[1]; + listener = container.resolve("repository").findComponent(Number(uid)); + } + + this.$__listeners__.set(`component-${listener.id}`, listener); + return `component-${listener.id}`; + } else { + let id = this.$__CALLBACK_ID__++; + this.$__listeners__.set(`callback-${id}`, listener); + return `callback-${id}`; + } + } + + /** + * Adds a listener that is automatically removed once the event is fired + * @param {Component|Function} listener + * @returns + */ + once(listener) { + let id = null; + let onceWrapper = null; + + if (listener && listener.id && typeof listener.wrap === "function") { + id = "component-" + listener.id; + + onceWrapper = { + id, + + wrap: ((...args) => { + this.off(id); + return listener.wrap(...args); + }).bind(this), + }; + } else { + id = "callback-" + this.$__CALLBACK_ID__++; + onceWrapper = ((...args) => { + this.off(id); + return listener(...args); + }).bind(this); + } - this.$__CALLBACK_ID__ = 0; + this.$__listeners__.set(id, onceWrapper); + + return id; + } + + /** + * Removes a Component + * @param {string} id + * @returns + */ + off(id) { + return this.$__listeners__.delete(id); + } + + /** + * Fires on state change + * @param {...any} args + * @returns + */ + async fire(...args) { + for (let [k, listener] of this.$__listeners__) { + if (/^callback-\d+$/.test(k)) { + listener(this, ...args); + } else { + listener.wrap(...args, this.$__signature__); + } + } - /** - * Tells the component to rerender - */ - this.$__signature__ = { - "called-by-state-change": true, - self: this, - }; + return this; + } - this.$__listeners__ = new Map(); + *[Symbol.iterator]() { + if (typeof this.value !== "object") { + yield this.value; + } else { + for (let k in this.value) { + yield this.value[k]; + } } + } + + toString() { + return `${this.value}`; + } + + /** + * Creates a new State + * @param {any} value + * @returns {State} + */ + static state(v = null) { + return ProxyFactory.make( + class extends State { + constructor() { + super(); + this.value = v; + this.$__id__ = State.count++; + + /** + * Tells the component to rerender + */ + this.$__signature__ = { + "called-by-state-change": true, + stateId: this.$__id__, + }; - /** - * Add a component that listens to this state - * @param {Component|Function} listener - * @returns - */ - listener(listener) { - // Assuming Component check via duck typing or name if circular dependency prevents instanceof - if (listener && listener.name && typeof listener.wrap === 'function') { - this.$__listeners__.set(listener.name, listener); - return listener.name; - } else { - let id = this.$__CALLBACK_ID__++; - this.$__listeners__.set(`callback-${id}`, listener); - return `callback-${id}`; + container.resolve("repository").addState(this); } - } - /** - * Adds a listener that is automatically removed once the event is fired - * @param {Component|Function} listener - * @returns - */ - once(listener) { - let id = null; - let onceWrapper = null; - - if (listener && listener.name && typeof listener.wrap === 'function') { - id = listener.name; - - onceWrapper = { - name: id, - - wrap: ((...args) => { - this.off(id); - return listener.wrap(...args); - }).bind(this), - }; - } else { - id = `callback-${this.$__CALLBACK_ID__++}`; - onceWrapper = ((...args) => { - this.off(id); - return listener(...args); - }).bind(this); - } - - this.$__listeners__.set(id, onceWrapper); + push = (...args) => { + if (!Array.isArray(this.value)) { + throw Error( + "State.Exception: Cannot execute push on a state whose value is not an array" + ); + } - return id; - } + this.value.push(...args); + this.$__changed__ = true; - /** - * Removes a Component - * @param {string} id - * @returns - */ - off(id) { - return this.$__listeners__.delete(id); - } + this.fire(); + }; + }, + class { + set(target, prop, value) { + if (prop === "value") { + let current = target.value; + let nVal = value; + + if (typeof nVal !== "object" && current === nVal) return true; + + Reflect.set(...arguments); + + target.$__changed__ = true; + + target.fire(); + + return true; + } else if ( + !( + prop in + { + $__listeners__: true, + $__signature__: true, + $__CALLBACK_ID__: true, + } + ) && + target.value[prop] !== value + ) { + target.value[prop] = value; + target.$__changed__ = true; + + target.fire(); + + return true; + } + + return Reflect.set(...arguments); + } - /** - * Fires on state change - * @param {...any} args - * @returns - */ - async fire(...args) { - for (let [k, listener] of this.$__listeners__) { - if (/^callback-\d+$/.test(k)) { - listener(this, ...args); - } else { - listener.wrap(...args, this.$__signature__); - } + get(target, prop, receiver) { + if (prop === "length" && typeof target.value === "object") { + return Object.keys(target.value).length; + } + + if ( + typeof prop !== "symbol" && + /\d+/.test(prop) && + Array.isArray(target.value) + ) { + return target.value[prop]; + } + + if ( + !target[prop] && + target.value && + typeof target.value === "object" && + target.value[prop] + ) + return target.value[prop]; + + return Reflect.get(...arguments); } - return this; - } + deleteProperty(target, prop) { + if (typeof target.value !== "object") return false; - *[Symbol.iterator]() { - if (typeof this.value !== "object") { - yield this.value; - } else { - for (let k in this.value) { - yield this.value[k]; - } - } - } + if (Array.isArray(target.value)) { + target.value = target.value.filter((v, i) => i != prop); + } else { + delete target.value[prop]; + } - toString() { - return `${this.value}`; - } + target.$__changed__ = true; + target.fire(); - /** - * Creates a new State - * @param {any} value - * @returns {State} - */ - static state(v = null) { - return ProxyFactory.make( - class extends State { - constructor() { - super(); - this.value = v; - this.$__id__ = State.count++; - } - - push = (...args) => { - if (!Array.isArray(this.value)) { - throw Error( - "State.Exception: Cannot execute push on a state whose value is not an array" - ); - } - - this.value.push(...args); - this.$__changed__ = true; - - this.fire(); - }; - }, - class { - set(target, prop, value) { - if (prop === "value") { - let current = target.value; - let nVal = value; - - if (typeof nVal !== "object" && current === nVal) - return true; - - Reflect.set(...arguments); - - target.$__changed__ = true; - - target.fire(); - - return true; - } else if ( - !( - prop in - { - $__listeners__: true, - $__signature__: true, - $__CALLBACK_ID__: true, - } - ) && - target.value[prop] !== value - ) { - target.value[prop] = value; - target.$__changed__ = true; - - target.fire(); - - return true; - } - - return Reflect.set(...arguments); - } - - get(target, prop, receiver) { - if ( - prop === "length" && - typeof target.value === "object" - ) { - return Object.keys(target.value).length; - } - - if ( - typeof prop !== "symbol" && - /\d+/.test(prop) && - Array.isArray(target.value) - ) { - return target.value[prop]; - } - - if ( - !target[prop] && - target.value && - typeof target.value === "object" && - target.value[prop] - ) - return target.value[prop]; - - return Reflect.get(...arguments); - } - - deleteProperty(target, prop) { - if (typeof target.value !== "object") return false; - - if (Array.isArray(target.value)) { - target.value = target.value.filter( - (v, i) => i != prop - ); - } else { - delete target.value[prop]; - } - - target.$__changed__ = true; - target.fire(); - - return true; - } - } - ); - } + return true; + } + } + ); + } } diff --git a/src/index.js b/src/index.js index dae90ef..59e49be 100644 --- a/src/index.js +++ b/src/index.js @@ -5,8 +5,8 @@ import State from "./core/State.js"; import ContextProvider from "./core/ContextProvider.js"; import Context from "./core/Context.js"; import ProxyFactory from "./core/ProxyFactory.js"; -import AutoLoader from "./core/AutoLoader.js"; import Container, { container } from "./core/Container.js"; +import Repository from "./core/Repository.js"; import Router from "./router/Router.js"; @@ -15,7 +15,6 @@ import BrokerRegistrar from "./broker/BrokerRegistrar.js"; import Listener from "./broker/Listener.js"; import Mediator from "./mediator/Mediator.js"; -import MediatorManager from "./mediator/MediatorManager.js"; import Component from "./component/Component.js"; import DOMReconciler from "./component/DOMReconciler.js"; @@ -24,27 +23,27 @@ import MarkupHandler from "./component/MarkupHandler.js"; import Utils from "./utils/Utils.js"; import DOM from "./utils/DOM.js"; -import { isClass, namespace } from "./utils/helpers.js"; +import { cleanUpNode, isClass } from "./utils/helpers.js"; // Initialize global instances const broker = new Broker(); const router = new Router(); const contextProvider = new ContextProvider(); -const mediatorManager = new MediatorManager(); -const loader = new AutoLoader(); -const autoload = new AutoLoader(); const h = MarkupHandler.proxy(); - +const repository = new Repository(); +const componentMethods = new WeakMap(); +const componentListeners = new WeakMap(); // Register global instances in container container.value("broker", broker); container.value("router", router); container.value("contextProvider", contextProvider); -container.value("mediatorManager", mediatorManager); -container.value("loader", loader); -container.value("autoload", autoload); +container.value("componentMethods", componentMethods); +container.value("componentListeners", componentListeners); +container.value("repository", repository); container.value("h", h); -container.value("component", component); + +router.reset = State.state(false); let ojsRouterEvents = { ojs: { @@ -58,8 +57,6 @@ broker.registerEvents(ojsRouterEvents); // Global Helpers const state = State.state; const ojs = (...classDeclarations) => new Runner().run(...classDeclarations); -const req = (qualifiedName) => loader.req(qualifiedName); -const include = (qualifiedName) => loader.include(qualifiedName); const v = (state, callback = (state) => state.value, ...args) => h.$anonymous(state, callback, ...args); const context = (name) => contextProvider.context(name); @@ -67,12 +64,7 @@ const putContext = (referenceName, qualifiedName) => contextProvider.load(referenceName, qualifiedName); const lazyFor = Utils.lazyFor; const each = Utils.each; -const component = (name) => h.getComponent(name); -const mediators = (names) => { - for (let qn of names) { - mediatorManager.fetchMediators(qn); - } -}; +const component = (componentId) => app("repository").findComponent(componentId); const eData = (meta = {}, message = {}) => { return new EventData().meta(meta).message(message).encode(); }; @@ -83,12 +75,29 @@ const ifElse = Utils.ifElse; const coalesce = Utils.coalesce; const dom = DOM; +const addNecessaryGlobals = () => { + if (!window) return; + + window.component = component; + window.each = each; + window.ifElse = ifElse; + window.coalesce = coalesce; + window.dom = dom; + window.eData = eData; + window.payload = payload; +}; + /** * Access services from the IoC container * @overload * @param {'h'} instance - Get the MarkupEngine instance * @returns {MarkupEngine} */ +/** + * @overload + * @param {'repository'} instance - Get the Repository instance + * @returns {Repository} + */ /** * @overload * @param {'router'} instance - Get the Router instance @@ -104,15 +113,15 @@ const dom = DOM; * @param {'contextProvider'} instance - Get the ContextProvider instance * @returns {ContextProvider} */ -/** +/** * @overload * @param {'mediatorManager'} instance - Get the MediatorManager instance * @returns {MediatorManager} */ /** * @overload - * @param {'loader'} instance - Get the AutoLoader instance - * @returns {AutoLoader} + * @param {'repository'} instance - Get the Repository instance + * @returns {Repository} */ /** * @overload @@ -136,6 +145,12 @@ const app = (instance = null, defaultValue = null) => { return container.resolve(instance, defaultValue); }; +/** + * Removes OpenScript modifications from a node + * @param {Node} node + */ +const removeNodeModifications = (node) => cleanUpNode(node); + // Export everything export { Runner, @@ -145,13 +160,11 @@ export { ContextProvider, Context, ProxyFactory, - AutoLoader, Router, Broker, BrokerRegistrar, Listener, Mediator, - MediatorManager, Component, DOMReconciler, MarkupEngine, @@ -160,13 +173,11 @@ export { DOM, app, isClass, - namespace, Container, container, state, + repository, ojs, - req, - include, v, context, putContext, @@ -176,10 +187,10 @@ export { coalesce, dom, component, - mediators, eData, payload, ojsRouterEvents, + removeNodeModifications, }; // Default export object @@ -191,13 +202,11 @@ export default { ContextProvider, Context, ProxyFactory, - AutoLoader, Router, Broker, BrokerRegistrar, Listener, Mediator, - MediatorManager, Component, DOMReconciler, MarkupEngine, @@ -206,13 +215,11 @@ export default { DOM, app, isClass, - namespace, Container, container, state, + repository, ojs, - req, - include, v, context, putContext, @@ -222,8 +229,61 @@ export default { coalesce, dom, component, - mediators, eData, payload, ojsRouterEvents, + removeNodeModifications, }; + +// Add necessary globals +addNecessaryGlobals(); + +// clean up +const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + //iterate through all child nodes and remove modifications + if (node.nodeType !== 1) continue; + + let childNodes = node.querySelectorAll("*"); + for (const childNode of childNodes) { + removeNodeModifications(childNode); + } + + removeNodeModifications(node); + + if (/OJS-.*/g.test(node.nodeName)) { + node.querySelectorAll(".__ojs-c-class__").forEach((n) => { + let uid = Number(n.getAttribute("uid")); + let instance = component(uid); + + if (!instance) return; + instance.unmount(); + app("repository").removeComponent(uid); + }); + + let uid = Number(node.getAttribute("uid")); + + if (uid) { + let instance = component(uid); + + if (!instance) continue; + instance.unmount(); + app("repository").removeComponent(uid); + } + } + } + } + + for (let [id, component] of app("repository").components) { + if (component.markup().length === 0) { + component.unmount(); + app("repository").removeComponent(id); + } + } +}); + +observer.observe(document.documentElement, { + childList: true, + subtree: true, +}); diff --git a/src/mediator/Mediator.js b/src/mediator/Mediator.js index 91e1330..5dac336 100644 --- a/src/mediator/Mediator.js +++ b/src/mediator/Mediator.js @@ -5,6 +5,17 @@ import { container } from "../core/Container.js"; * The Mediator Class */ export default class Mediator { + static mediatorId = 0; + + constructor() { + this.id = Mediator.mediatorId++; + container.resolve("repository").addMediator(this); + } + + /** + * Should the mediator be registered + * @returns {boolean} + */ shouldRegister() { return true; } diff --git a/src/mediator/MediatorManager.js b/src/mediator/MediatorManager.js deleted file mode 100644 index 4af6f55..0000000 --- a/src/mediator/MediatorManager.js +++ /dev/null @@ -1,47 +0,0 @@ -import Mediator from "./Mediator.js"; -// import AutoLoader from "../core/AutoLoader.js"; // Need to find AutoLoader - -/** - * The Mediator Manager - */ -export default class MediatorManager { - static directory = "./mediators"; - static version = "1.0.0"; - - constructor() { - this.mediators = new Map(); - } - - /** - * Fetch Mediators from the Backend - * @param {string} qualifiedName - */ - async fetchMediators(qualifiedName) { - // Assuming AutoLoader is available globally or imported - // For now, commenting out AutoLoader usage until I find it - /* - let MediatorClass = await new AutoLoader( - MediatorManager.directory, - MediatorManager.version - ).include(qualifiedName); - - if (!MediatorClass) { - MediatorClass = new Map([qualifiedName, ["_", Mediator]]); - } - - for (let [k, v] of MediatorClass) { - try { - if (this.mediators.has(k)) continue; - - let mediator = new v[1](); - mediator.register(); - - this.mediators.set(k, mediator); - } catch (e) { - console.error(`Unable to load '${k}' Mediator.`, e); - } - } - */ - console.warn("MediatorManager.fetchMediators is not fully implemented yet due to missing AutoLoader."); - } -} diff --git a/src/router/Router.js b/src/router/Router.js index 3ebb84f..30c2205 100644 --- a/src/router/Router.js +++ b/src/router/Router.js @@ -76,7 +76,7 @@ export default class Router { this.GroupedRoute = class GroupedRoute {}; - this.reset = State.state(false); + this.reset = null; window.addEventListener("popstate", () => { this.reset.value = true; diff --git a/src/utils/helpers.js b/src/utils/helpers.js index 44ebe9d..bb261ae 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -1,13 +1,15 @@ +import { container } from "../core/Container"; + /** * Checks if a function is a class - * @param {function} func + * @param {function} func * @returns {boolean} */ export function isClass(func) { - return ( - typeof func === "function" && - /^class\s/.test(Function.prototype.toString.call(func)) - ); + return ( + typeof func === "function" && + /^class\s/.test(Function.prototype.toString.call(func)) + ); } /** @@ -15,6 +17,49 @@ export function isClass(func) { * @param {string} name */ export function namespace(name) { - if (!window[name]) window[name] = {}; - return window[name]; + if (!window[name]) window[name] = {}; + return window[name]; +} + +export function cleanUpNode(node) { + node.__eventListeners = null; + node.__methods = null; + node.__openscript_cleanup__?.(); +} + +/** + * Handles events for DOM elements + * @param {Event} event + */ +export function indirectEventHandler(event) { + let target = event.currentTarget; + let type = event.type; + + let eventMap = container.resolve("repository").domListeners.get(target); + + if (!eventMap) return; + + let listeners = eventMap.get(type) ?? []; + + for (let listener of listeners) { + listener(event); + } } + + +export function defineDomMethod(node, name) { + if (node[name]) return; + + Object.defineProperty(node, name, { + configurable: true, + enumerable: false, + value: function (...args) { + const methods = container.resolve("repository").domMethods.get(this); + const fn = methods?.get(name); + return fn?.call(this, ...args); + } + }); +} + + + From 65ef9a1230f8aa530e5c9d53684141006d4e068b Mon Sep 17 00:00:00 2001 From: levizwannah Date: Thu, 15 Jan 2026 14:53:47 +0300 Subject: [PATCH 28/46] first level of memory leak contained --- src/component/Component.js | 154 +++++++++---- src/component/DOMReconciler.js | 385 +++++++++++++++++---------------- src/component/MarkupEngine.js | 70 ++---- src/index.js | 2 + src/utils/helpers.js | 40 +++- 5 files changed, 367 insertions(+), 284 deletions(-) diff --git a/src/component/Component.js b/src/component/Component.js index 3a275c2..ec1cef4 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -3,6 +3,10 @@ import DOMReconciler from "./DOMReconciler.js"; import BrokerRegistrar from "../broker/BrokerRegistrar.js"; import State from "../core/State.js"; import { container } from "../core/Container.js"; +import { + cleanupDisconnectedComponents, + getOjsChildren, +} from "../utils/helpers.js"; /** * Base Component Class @@ -33,10 +37,10 @@ export default class Component { * List of events that the component emits */ this.EVENTS = { - rendered: "rendered", // component is visible on the dom - rerendered: "rerendered", // component was rerendered - mounted: "mounted", // the component is now registered - unmounted: "unmounted", // removed from the markup engine memory + rendered: "rendered", // component ui is computed + rerendered: "rerendered", // component was ui was recomputed. + mounted: "mounted", // the component is now on the dom + unmounted: "unmounted", // removed from the repository }; /** @@ -50,6 +54,11 @@ export default class Component { */ this.mounted = false; + /** + * Has the component being unmounted + */ + this.unmounted = false; + /** * Has the component rendered */ @@ -60,11 +69,6 @@ export default class Component { */ this.rerendered = false; - /** - * Is the component visible - */ - this.visible = true; - /** * The argument Map for rerendering on state changes */ @@ -85,9 +89,29 @@ export default class Component { this.name = name ?? this.constructor.name; - this.emitter.once(this.EVENTS.rendered, (th) => (th.rendered = true)); - this.on(this.EVENTS.rerendered, (th) => (th.rerendered = true)); - this.on(this.EVENTS.mounted, (th) => (th.mounted = true)); + this.emitter.once(this.EVENTS.rendered, (componentId) => { + console.log("Component rendered:", componentId); + let repo = container.resolve("repository"); + let component = repo.findComponent(componentId); + if (component) component.rendered = true; + console.log("Component rendered:", component); + }); + + this.on(this.EVENTS.rerendered, (componentId) => { + console.log("Component rerendered:", componentId); + let repo = container.resolve("repository"); + let component = repo.findComponent(componentId); + if (component) component.rerendered = true; + console.log("Component rerendered:", component); + }); + + this.on(this.EVENTS.mounted, (componentId) => { + console.log("Component mounted:", componentId); + let repo = container.resolve("repository"); + let component = repo.findComponent(componentId); + if (component) component.handleMounted(); + console.log("Component mounted:", component); + }); /** * Compare two Nodes @@ -193,7 +217,7 @@ export default class Component { } this.releaseMemory(); - this.mounted = false; + this.unmounted = true; return true; } @@ -203,8 +227,10 @@ export default class Component { * @param {string} event * @param {Array<*>} args */ - emit(event, args = []) { - this.emitter.emit(event, this, event, ...args); + emit(event, ...args) { + args.push(event); + + this.emitter.emit(event, ...args); } /** @@ -325,7 +351,12 @@ export default class Component { final.parent = args[i].parent; } - const keys = ["resetParent", "replaceParent", "firstOfParent"]; + const keys = [ + "resetParent", + "replaceParent", + "firstOfParent", + "reconcileParent", + ]; for (let reserved of keys) { if (args[i][reserved]) { @@ -360,8 +391,17 @@ export default class Component { wrap(...args) { const h = container.resolve("h"); const lastArg = args[args.length - 1]; - let { index, parent, resetParent, states, replaceParent, firstOfParent } = - this.getParentAndListen(args); + let { + index, + parent, + resetParent, + states, + replaceParent, + firstOfParent, + reconcileParent, + } = this.getParentAndListen(args); + + let reconciler = new this.Reconciler(); // check if the render was called due to a state change if (lastArg && lastArg["called-by-state-change"]) { @@ -371,21 +411,15 @@ export default class Component { let current = h.dom.querySelectorAll( - `ojs-${this.kebab(this.name)}[uid="${this.id}"][s-${stateId}="${ - stateId - }"]` + `ojs-${this.kebab(this.name)}[uid="${ + this.id + }"][s-${stateId}="${stateId}"]` ) ?? []; - let reconciler = new this.Reconciler(); - current.forEach((e) => { let arg = this.argsMap.get(Number(e.getAttribute("uid"))); - let attr = { - componentId: this.id, - event: this.EVENTS.rerendered, - eventParams: [{ componentId: this.id }], - }; + let attr = {}; let shouldReconcile = true; @@ -405,8 +439,11 @@ export default class Component { reconciler.reconcile(markup, e.childNodes[0]); } } + + this.emit(this.EVENTS.rerendered, this.id); }); + queueMicrotask(cleanupDisconnectedComponents); return; } @@ -437,7 +474,15 @@ export default class Component { class: "__ojs-c-class__", }; - if (parent) attr.parent = parent; + // we render in the parent node + // if we don't need to reconcile the parent + if (parent) { + if (reconcileParent) { + attr.parent = parent.cloneNode(); + } else { + attr.parent = parent; + } + } states.forEach((id) => { attr[`s-${id}`] = id; @@ -451,8 +496,6 @@ export default class Component { return children.length > 1 ? children : children[0]; } - if (!this.visible) attr.style = "display: none;"; - let cAttributes = {}; if (markup instanceof HTMLElement) { @@ -460,14 +503,25 @@ export default class Component { markup.setAttribute("c-attr", ""); } - attr = { - ...attr, - componentId: this.id, - event, - eventParams: [{ componentId: this.id }], - }; + const rendered = h[`ojs-${this.kebab(this.name)}`]( + attr, + markup, + cAttributes + ); + + if (reconcileParent && parent) { + reconciler.reconcile(rendered.parentElement, parent); + } - return h[`ojs-${this.kebab(this.name)}`](attr, markup, cAttributes); + this.emit(this.EVENTS.rendered, this.id); + + if (parent && parent.isConnected) { + this.emit(this.EVENTS.mounted, this.id); + } + + queueMicrotask(cleanupDisconnectedComponents); + + return rendered; } isHtml(markup) { @@ -524,4 +578,28 @@ export default class Component { removeListener(eventName, listener) { return this.emitter.removeListener(eventName, listener); } + + handleMounted() { + if (this.mounted) return; + + this.mounted = true; + + // get all components under this component and + // fire their mounted event. + + let markups = this.markup(); + + let root = markups[0]; + + if (!root) return; + + let children = getOjsChildren(root); + + children.forEach((child) => { + let component = container + .resolve("repository") + .findComponent(child.getAttribute("uid")); + if (component) component.emit(this.EVENTS.mounted, component.id); + }); + } } diff --git a/src/component/DOMReconciler.js b/src/component/DOMReconciler.js index 6e5cc45..68b81f1 100644 --- a/src/component/DOMReconciler.js +++ b/src/component/DOMReconciler.js @@ -1,215 +1,222 @@ +import { container } from "../core/Container"; +import { destroyNodeDeep, removeDomMethod } from "../utils/helpers"; + /** * DOMReconciler Class */ export default class DOMReconciler { - /** - * @param {Node} domNode - * @param {Node} newNode - */ - replace(domNode, newNode) { - try { - return domNode.parentNode.replaceChild(newNode, domNode); - } catch (e) { - console.error(e, domNode, domNode.parentNode); - } + /** + * @param {Node} domNode + * @param {Node} newNode + */ + replace(domNode, newNode) { + try { + destroyNodeDeep(domNode); + return domNode.parentNode.replaceChild(newNode, domNode); + } catch (e) { + console.error(e, domNode, domNode.parentNode); + } + } + + /** + * Replaces the attributes of node1 with that of node2 + * @param {HTMLElement} node1 + * @param {HTMLElement} node2 + */ + replaceAttributes(node1, node2) { + let length1 = node1.attributes.length; + let length2 = node2.attributes.length; + + let remove = []; + let add = []; + + let mx = Math.max(length1, length2); + + for (let i = 0; i < mx; i++) { + if (i >= length1) { + let attr = node2.attributes[i]; + add.push({ name: attr.name, value: attr.value }); + continue; + } + + if (i >= length2) { + let attr = node1.attributes[i]; + remove.push(attr.name); + continue; + } + + let attr1 = node1.attributes[i]; + let attr2 = node2.attributes[i]; + + if (!node2.hasAttribute(attr1.name)) { + remove.push(attr1.name); + } else if (attr1.value != node2.getAttribute(attr1.name)) { + add.push({ + name: attr1.name, + value: node2.getAttribute(attr1.name), + }); + } + + if (attr2.value != node1.getAttribute(attr2.name)) { + add.push({ name: attr2.name, value: attr2.value }); + } } - /** - * Replaces the attributes of node1 with that of node2 - * @param {HTMLElement} node1 - * @param {HTMLElement} node2 - */ - replaceAttributes(node1, node2) { - let length1 = node1.attributes.length; - let length2 = node2.attributes.length; - - let remove = []; - let add = []; - - let mx = Math.max(length1, length2); - - for (let i = 0; i < mx; i++) { - if (i >= length1) { - let attr = node2.attributes[i]; - add.push({ name: attr.name, value: attr.value }); - continue; - } - - if (i >= length2) { - let attr = node1.attributes[i]; - remove.push(attr.name); - continue; - } - - let attr1 = node1.attributes[i]; - let attr2 = node2.attributes[i]; - - if (!node2.hasAttribute(attr1.name)) { - remove.push(attr1.name); - } else if (attr1.value != node2.getAttribute(attr1.name)) { - add.push({ - name: attr1.name, - value: node2.getAttribute(attr1.name), - }); - } - - if (attr2.value != node1.getAttribute(attr2.name)) { - add.push({ name: attr2.name, value: attr2.value }); - } - } - - mx = Math.max(remove.length, add.length); - let mem = new Set(); - - for (let i = 0; i < mx; i++) { - if (i < remove.length && !mem.has(remove[i])) { - node1.removeAttribute(remove[i]); - } - if (i < add.length) { - node1.setAttribute(add[i].name, add[i].value); - mem.add(add[i].name); - } - } + mx = Math.max(remove.length, add.length); + let mem = new Set(); + + for (let i = 0; i < mx; i++) { + if (i < remove.length && !mem.has(remove[i])) { + node1.removeAttribute(remove[i]); + } + if (i < add.length) { + node1.setAttribute(add[i].name, add[i].value); + mem.add(add[i].name); + } } + } + + /** + * + * @param {Node} node1 + * @param {Node} node2 + * @returns + */ + equal(node1, node2) { + return node1?.isEqualNode(node2) == true; + } + + getEventListeners(node) { + if (node.getEventListeners) return node.getEventListeners(); + return new Map(); + } + + replaceEventListeners(targetNode, sourceNode) { + const events = this.getEventListeners(targetNode); + + for (const [eventName, listeners] of events) { + listeners.forEach((listener) => { + targetNode.removeListener(eventName, listener); + }); + } + + const sourceEvents = this.getEventListeners(sourceNode); - /** - * - * @param {Node} node1 - * @param {Node} node2 - * @returns - */ - equal(node1, node2) { - return node1?.isEqualNode(node2) == true; + for (const [eventName, listeners] of sourceEvents) { + listeners.forEach((listener) => { + targetNode.addListener(eventName, listener); + }); } + } - getEventListeners(node) { - if (!node.__eventListeners) { - node.__eventListeners = {}; - } - return node.__eventListeners || {}; + replaceAddedMethods(targetNode, sourceNode) { + // get the methods from the repository + let methodsMap = + container.resolve("repository").domMethods.get(sourceNode) ?? new Map(); + + let targetMethods = + container.resolve("repository").domMethods.get(targetNode) ?? new Map(); + + if (!targetMethods) { + targetMethods = new Map(); + container.resolve("repository").domMethods.set(targetNode, targetMethods); } - replaceEventListeners(targetNode, sourceNode) { - const events = this.getEventListeners(targetNode); + for (const [name, fn] of methodsMap) { + removeDomMethod(targetNode, name); + targetMethods.set(name, fn); + defineDomMethod(targetNode, name); + } - for (const eventName in events) { - events[eventName].forEach((listener) => { - targetNode.removeListener(eventName, listener); - }); - } + return; + } + + /** + * + * @param {Node|HTMLElement} current + * @param {Node|HTMLElement} previous - currently on the DOM + */ + reconcile(current, previous) { + if (this.isText(current)) { + this.replace(previous, current); + return true; + } - const sourceEvents = this.getEventListeners(sourceNode); + this.replaceEventListeners(previous, current); + this.replaceAddedMethods(previous, current); - for (const eventName in sourceEvents) { - sourceEvents[eventName].forEach((listener) => { - targetNode.addListener(eventName, listener); - }); - } + if (this.equal(current, previous)) { + return false; } - replaceAddedMethods(targetNode, sourceNode) { - if (!sourceNode.__methods) { - return; - } + if (this.isElement(current) && this.isElement(previous)) { + if (current.tagName !== previous.tagName) { + this.replace(previous, current); + return true; + } - targetNode.__methods = {}; + this.replaceAttributes(previous, current); - for (let m in sourceNode.__methods) { - targetNode.__methods[m] = sourceNode.__methods[m]; - } + if (this.equal(previous, current)) { + return false; + } - return; - } + let i = 0, + j = 0; + let prevLength = previous.childNodes.length; + let curLength = current.childNodes.length; + let _pc = curLength; - /** - * - * @param {Node|HTMLElement} current - * @param {Node|HTMLElement} previous - currently on the DOM - */ - reconcile(current, previous) { - if (this.isText(current)) { - this.replace(previous, current); - return true; - } - - this.replaceEventListeners(previous, current); - this.replaceAddedMethods(previous, current); - - if (this.equal(current, previous)) { - return false; - } - - if (this.isElement(current) && this.isElement(previous)) { - if (current.tagName !== previous.tagName) { - this.replace(previous, current); - return true; - } - - this.replaceAttributes(previous, current); - - if (this.equal(previous, current)) { - return false; - } - - let i = 0, - j = 0; - let prevLength = previous.childNodes.length; - let curLength = current.childNodes.length; - let _pc = curLength; - - while (i < prevLength && j < curLength) { - this.reconcile( - current.childNodes[j], - previous.childNodes[i] - ); - - _pc = curLength; - curLength = current.childNodes.length; - - if (_pc === curLength) j++; - - i++; - } - - while (i < previous.childNodes.length) { - previous.childNodes[i]?.remove(); - } - - while (j < current.childNodes.length) { - previous.append(current.childNodes[j]); - } - - return true; - } else { - this.replace(previous, current); - return true; - } - } + while (i < prevLength && j < curLength) { + this.reconcile(current.childNodes[j], previous.childNodes[i]); - /** - * - * @param {Node} node - */ - isText(node) { - return node.nodeType === Node.TEXT_NODE; - } + _pc = curLength; + curLength = current.childNodes.length; - /** - * - * @param {Node} node - * @returns - */ - isElement(node) { - return node.nodeType === Node.ELEMENT_NODE; - } + if (_pc === curLength) j++; + + i++; + } + + while (i < previous.childNodes.length) { + previous.childNodes[i]?.remove(); + } + + while (j < current.childNodes.length) { + previous.append(current.childNodes[j]); + } - /** - * - * @param {object} attr1 - * @param {object} attr2 - * @returns - */ - attributesEq(attr1, attr2) { - return JSON.stringify(attr1) == JSON.stringify(attr2); + return true; + } else { + this.replace(previous, current); + return true; } + } + + /** + * + * @param {Node} node + */ + isText(node) { + return node.nodeType === Node.TEXT_NODE; + } + + /** + * + * @param {Node} node + * @returns + */ + isElement(node) { + return node.nodeType === Node.ELEMENT_NODE; + } + + /** + * + * @param {object} attr1 + * @param {object} attr2 + * @returns + */ + attributesEq(attr1, attr2) { + return JSON.stringify(attr1) == JSON.stringify(attr2); + } } diff --git a/src/component/MarkupEngine.js b/src/component/MarkupEngine.js index a86e9ac..4ff89c8 100644 --- a/src/component/MarkupEngine.js +++ b/src/component/MarkupEngine.js @@ -3,7 +3,7 @@ import Utils from "../utils/Utils.js"; import Component from "./Component.js"; import State from "../core/State.js"; import { container } from "../core/Container.js"; -import { indirectEventHandler, isClass } from "../utils/helpers.js"; +import { defineDomMethod, indirectEventHandler, isClass } from "../utils/helpers.js"; /** * Base Markup Engine Class @@ -105,11 +105,9 @@ export default class MarkupEngine { // remove the listener from the event map in the repository // since for each event, there is only the indirect event handler // listening to that event. - let eventMap = container.resolve("repository").domListeners.get(this); + let eventMap = container.resolve("repository").domListeners.get(this) ?? new Map(); - let listeners = eventMap.get(event); - - if (!listeners) return; + let listeners = eventMap.get(event) ?? new Set(); for (let l of listeners) { if (l === listener) { @@ -121,11 +119,12 @@ export default class MarkupEngine { if (listeners.size === 0) { eventMap.delete(event); this.__eventListeners.delete(event); + this.removeEventListener(event, indirectEventHandler); } }; element.getEventListeners = function () { - return container.resolve("repository").domListeners.get(this); + return container.resolve("repository").domListeners.get(this) ?? new Map(); }; element.toString = function () { @@ -136,7 +135,7 @@ export default class MarkupEngine { let methods = {}; // get the methods from the repository - let methodsMap = container.resolve("repository").domMethods.get(this); + let methodsMap = container.resolve("repository").domMethods.get(this) ?? new Map(); for (let [k, v] of methodsMap) { methods[k] = v; @@ -145,9 +144,18 @@ export default class MarkupEngine { return methods; }; + element.removeAllListeners = function () { + this.__eventListeners?.forEach((event) => { + this.removeEventListener(event, indirectEventHandler); + }); + this.__eventListeners?.clear(); + }; + element.__openscript_cleanup__ = () => { + element.removeAllListeners(); delete element.addListener; delete element.removeListener; + delete element.removeAllListeners; delete element.getEventListeners; delete element.methods; delete element.__eventListeners; @@ -205,13 +213,6 @@ export default class MarkupEngine { return cmp.wrap(...args); } - let component; - let event = ""; - let eventParams = []; - - const isComponentName = (tag) => { - return /^ojs-.*$/.test(tag); - }; /** * @type {DocumentFragment|HTMLElement} @@ -261,30 +262,11 @@ export default class MarkupEngine { continue; } - if (k === "event" && typeof v === "string") { - event = v; - continue; - } - if (k === "replaceParent" && typeof v === "boolean") { replaceParent = v; continue; } - if (k === "eventParams") { - if (!Array.isArray(v)) v = [v]; - eventParams = v; - continue; - } - - if ( - k === "componentId" && - (typeof v === "string" || typeof v === "number") - ) { - component = Component.findComponent(v); - continue; - } - if (k === "c_attr") { componentAttribute = v; continue; @@ -449,24 +431,6 @@ export default class MarkupEngine { } } - if (component) { - component.emit(event, eventParams); - let sc = root.querySelectorAll(".__ojs-c-class__"); - - sc.forEach((c) => { - if (!isComponentName(c.tagName.toLowerCase())) return; - let cmp = Component.findComponent(Number(c.getAttribute("uid"))); - - if (!cmp) return; - - cmp?.emit(event, eventParams); - - if (parent.isConnected) { - cmp.mount(); - } - }); - } - return root; }; @@ -567,8 +531,8 @@ export default class MarkupEngine { container.resolve("repository").domListeners.set(node, eventMap); } - let listeners = eventMap.get(event) ?? []; - listeners.push(listener); + let listeners = eventMap.get(event) ?? new Set(); + listeners.add(listener); eventMap.set(event, listeners); }; } diff --git a/src/index.js b/src/index.js index 59e49be..94c2616 100644 --- a/src/index.js +++ b/src/index.js @@ -242,6 +242,7 @@ addNecessaryGlobals(); const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.removedNodes) { + //iterate through all child nodes and remove modifications if (node.nodeType !== 1) continue; @@ -287,3 +288,4 @@ observer.observe(document.documentElement, { childList: true, subtree: true, }); + diff --git a/src/utils/helpers.js b/src/utils/helpers.js index bb261ae..d25db13 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -22,9 +22,8 @@ export function namespace(name) { } export function cleanUpNode(node) { - node.__eventListeners = null; - node.__methods = null; node.__openscript_cleanup__?.(); + node.__eventListeners = null; } /** @@ -46,7 +45,6 @@ export function indirectEventHandler(event) { } } - export function defineDomMethod(node, name) { if (node[name]) return; @@ -57,9 +55,43 @@ export function defineDomMethod(node, name) { const methods = container.resolve("repository").domMethods.get(this); const fn = methods?.get(name); return fn?.call(this, ...args); - } + }, }); } +export function removeDomMethod(node, name) { + if (!node || !node[name]) return; + delete node[name]; +} + +export function destroyNodeDeep(node) { + if (node.nodeType !== 1) return; + + for (const child of [...node.children]) { + destroyNodeDeep(child); + } + + cleanUpNode(node); +} + +export function cleanupDisconnectedComponents() { + const repo = container.resolve("repository"); + + for (const [id, component] of repo.components) { + + if(!component?.mounted === true) continue; + + let markups = component.markup(); + + if (!markups.length || markups[0]?.isConnected === false) { + component.unmount(); + repo.removeComponent(id); + } + } +} + +export function getOjsChildren(parent) { + return parent?.querySelectorAll(".__ojs-c-class__") ?? []; +} From 8788b5e21392d84e3d77b346b9eb1be5eb19a414 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Thu, 15 Jan 2026 15:14:27 +0300 Subject: [PATCH 29/46] finished closing markups memory leaks from the framework's perspective --- src/component/Component.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/component/Component.js b/src/component/Component.js index ec1cef4..1fe35c7 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -90,27 +90,21 @@ export default class Component { this.name = name ?? this.constructor.name; this.emitter.once(this.EVENTS.rendered, (componentId) => { - console.log("Component rendered:", componentId); let repo = container.resolve("repository"); let component = repo.findComponent(componentId); if (component) component.rendered = true; - console.log("Component rendered:", component); }); this.on(this.EVENTS.rerendered, (componentId) => { - console.log("Component rerendered:", componentId); let repo = container.resolve("repository"); let component = repo.findComponent(componentId); if (component) component.rerendered = true; - console.log("Component rerendered:", component); }); this.on(this.EVENTS.mounted, (componentId) => { - console.log("Component mounted:", componentId); let repo = container.resolve("repository"); let component = repo.findComponent(componentId); if (component) component.handleMounted(); - console.log("Component mounted:", component); }); /** @@ -447,8 +441,6 @@ export default class Component { return; } - let event = this.EVENTS.rendered; - if ( parent && (this.getValue(resetParent) || this.getValue(replaceParent)) @@ -458,13 +450,11 @@ export default class Component { let all = this.markup(parent); all.forEach((elem) => this.argsMap.delete(elem.getAttribute("uid"))); } - - if (this.argsMap.size) event = this.EVENTS.rerendered; } let uuid = this.id; - this.argsMap.set(uuid, args ?? []); + if(states?.length) this.argsMap.set(uuid, args ?? []); let attr = { uid: uuid, From 5acd716ff2095c784b6ccf96c743e48bb07c44fb Mon Sep 17 00:00:00 2001 From: levizwannah Date: Thu, 15 Jan 2026 15:45:14 +0300 Subject: [PATCH 30/46] fixed memory leaks --- src/component/Component.js | 21 ++++++++------------- src/core/Repository.js | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/component/Component.js b/src/component/Component.js index 1fe35c7..b6b4a07 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -69,11 +69,6 @@ export default class Component { */ this.rerendered = false; - /** - * The argument Map for rerendering on state changes - */ - this.argsMap = new Map(); - /** * Event Emitter for the component */ @@ -112,6 +107,7 @@ export default class Component { */ this.Reconciler = DOMReconciler; container.resolve("repository").addComponent(this); + container.resolve("repository").addComponentArgs(this.id, []); } /** @@ -275,10 +271,10 @@ export default class Component { releaseMemory() { container.resolve("repository").removeComponent(this.id); + container.resolve("repository").removeComponentArgs(this.id); this.cleanUp(); this.emitter.clear(); - this.argsMap.clear(); for (let id in this.states) { this.states[id]?.off(`component-${this.id}`); @@ -411,7 +407,8 @@ export default class Component { ) ?? []; current.forEach((e) => { - let arg = this.argsMap.get(Number(e.getAttribute("uid"))); + let arg = + container.resolve("repository").getComponentArgs(this.id) ?? []; let attr = {}; @@ -445,16 +442,14 @@ export default class Component { parent && (this.getValue(resetParent) || this.getValue(replaceParent)) ) { - if (!this.markup().length) this.argsMap.clear(); - else { - let all = this.markup(parent); - all.forEach((elem) => this.argsMap.delete(elem.getAttribute("uid"))); - } + container.resolve("repository").addComponentArgs(this.id, []); } let uuid = this.id; - if(states?.length) this.argsMap.set(uuid, args ?? []); + if (states?.length) { + container.resolve("repository").addComponentArgs(this.id, args ?? []); + } let attr = { uid: uuid, diff --git a/src/core/Repository.js b/src/core/Repository.js index 09024ff..9077e4e 100644 --- a/src/core/Repository.js +++ b/src/core/Repository.js @@ -16,6 +16,12 @@ export default class Repository { */ this.components = new Map(); + /** + * Keeps arguments that was passed to the component + * during rendering when a state was passed. + */ + this.componentArgsMap = new WeakMap(); + /** * Map of State references. * @type {Map} @@ -111,6 +117,17 @@ export default class Repository { return this.mediators.get(Number(id)); } + /** + * Add arguments to the component + * @param {string|number} componentId + * @param {Array<*>} args + */ + addComponentArgs(componentId, args) { + let component = this.findComponent(componentId); + if (!component) return; + this.componentArgsMap.set(component, args); + } + /** * Remove a mediator from the repository * @param {string} id @@ -118,4 +135,25 @@ export default class Repository { removeMediator(id) { this.mediators.delete(Number(id)); } + + /** + * Get the arguments passed to the component + * @param {string|number} componentId + * @returns {Array<*>} + */ + getComponentArgs(componentId) { + let component = this.findComponent(componentId); + if (!component) return; + return this.componentArgsMap.get(component); + } + + /** + * Remove arguments from the component + * @param {string|number} componentId + */ + removeComponentArgs(componentId) { + let component = this.findComponent(componentId); + if (!component) return; + this.componentArgsMap.delete(component); + } } From 205eac3ee5e098a9465975af34d64c9115291573 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Thu, 15 Jan 2026 16:06:35 +0300 Subject: [PATCH 31/46] package updated --- .gitignore | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8a83deb..6f6cb74 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ package-lock.json yarn.lock pnpm-lock.yaml +.npmrc # Build outputs dist/ diff --git a/package.json b/package.json index 7314061..37a514c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "1.0.31", + "version": "2.0.0", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", From 0b10d6222a73ac3f5f3462989bd501efd5b3ab2f Mon Sep 17 00:00:00 2001 From: levizwannah Date: Thu, 15 Jan 2026 19:50:11 +0300 Subject: [PATCH 32/46] fixed minor bugs --- docs/COMPONENT_AUTO_IMPORT.md | 184 ------------------- docs/COMPONENT_AUTO_IMPORT_README_SECTION.md | 39 ---- docs/notes.md | 1 + package.json | 2 +- src/component/Component.js | 2 +- src/core/Runner.js | 39 ++-- src/core/State.js | 6 +- 7 files changed, 33 insertions(+), 240 deletions(-) delete mode 100644 docs/COMPONENT_AUTO_IMPORT.md delete mode 100644 docs/COMPONENT_AUTO_IMPORT_README_SECTION.md create mode 100644 docs/notes.md diff --git a/docs/COMPONENT_AUTO_IMPORT.md b/docs/COMPONENT_AUTO_IMPORT.md deleted file mode 100644 index 19af186..0000000 --- a/docs/COMPONENT_AUTO_IMPORT.md +++ /dev/null @@ -1,184 +0,0 @@ -# OpenScript Component Auto-Import System - -## How to Use (For Developers) - -### 1. Install OpenScript - -```bash -npm install modular-openscriptjs -``` - -### 2. Configure Vite - -```javascript -// vite.config.js -import { defineConfig } from "vite"; -import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; - -export default defineConfig({ - plugins: [ - openScriptComponentPlugin({ - componentsDir: "src/components", // default - autoRegister: true, // auto-register components - generateTypes: true, // generate .d.ts for IDE - }), - ], -}); -``` - -### 3. Create Components - -```javascript -// src/components/TodoList.js -import { Component, app, state } from "modular-openscriptjs"; - -const h = app("h"); - -export default class TodoList extends Component { - constructor() { - super(); - this.todos = state([]); - } - - render() { - return h.div( - h.h2("My Todos"), - h.ul(...this.todos.value.map((todo) => h.li(todo.text))) - ); - } -} -``` - -### 4. Use Components with Auto-Import - -```javascript -// src/main.js -import { app } from "modular-openscriptjs"; -import "virtual:openscript-components"; // Auto-imports all components - -const h = app("h"); - -// IDE will autocomplete ComponentName! -// Component is automatically imported and bundled by Vite! -h.TodoList({ parent: document.body }); -``` - -## Features - -✅ **IDE Autocomplete**: Type `h.` and see all your components -✅ **Auto-Import**: No need to manually import components -✅ **TypeScript Support**: Generated `.d.ts` files -✅ **HMR Support**: Hot module replacement during development -✅ **Automatic Bundling**: Vite includes all components in bundle -✅ **Nested Components**: Supports subdirectories in components/ - -## How It Works - -1. **Component Discovery**: Plugin scans `src/components/` for all Component files -2. **Type Generation**: Creates `openscript-components.d.ts` with type definitions -3. **Virtual Module**: Creates a virtual module that imports all components -4. **Auto-Registration**: Optionally auto-registers all components on app start -5. **IDE Support**: TypeScript definitions provide autocomplete and type checking - -## Advanced Usage - -### Manual Component Registration - -```javascript -// vite.config.js -openScriptComponentPlugin({ - autoRegister: false, // Disable auto-registration -}); -``` - -```javascript -// src/main.js -import components, { - registerAllComponents, -} from "virtual:openscript-components"; - -// Manually register when needed -await registerAllComponents(); - -// Or register individually -const todoList = new components.TodoList(); -await todoList.mount(); -``` - -### Custom Components Directory - -```javascript -openScriptComponentPlugin({ - componentsDir: "src/ui/components", -}); -``` - -### Exclude Components from Auto-Discovery - -Name files with lowercase or prefix with underscore: - -- `utils.js` ❌ (lowercase, won't be discovered) -- `_BaseComponent.js` ❌ (underscore prefix, won't be discovered) -- `TodoList.js` ✅ (PascalCase, will be discovered) - -## TypeScript Example - -```typescript -// src/main.ts -import { app } from "modular-openscriptjs"; -import "virtual:openscript-components"; - -const h = app("h"); - -// Full type safety! -h.TodoList({ - parent: document.body, - resetParent: true, -}); -``` - -## Comparison with JSX - -**JSX/React:** - -```jsx -import TodoList from "./components/TodoList"; -; -``` - -**OpenScript (with plugin):** - -```javascript -import "virtual:openscript-components"; -h.TodoList(); -``` - -Both provide: - -- ✅ IDE autocomplete -- ✅ Type checking -- ✅ Proper bundling -- ✅ HMR support - -## Migration from Manual Imports - -**Before:** - -```javascript -import TodoList from "./components/TodoList"; -import Header from "./components/Header"; - -const todoList = new TodoList(); -await todoList.mount(); -h.TodoList({ parent: document.body }); -``` - -**After:** - -```javascript -import "virtual:openscript-components"; - -// Components auto-registered and available on h! -h.TodoList({ parent: document.body }); -h.Header({ parent: document.body }); -``` diff --git a/docs/COMPONENT_AUTO_IMPORT_README_SECTION.md b/docs/COMPONENT_AUTO_IMPORT_README_SECTION.md deleted file mode 100644 index 2bc89a0..0000000 --- a/docs/COMPONENT_AUTO_IMPORT_README_SECTION.md +++ /dev/null @@ -1,39 +0,0 @@ -## Component Auto-Import Feature - -OpenScript provides automatic component discovery and import, similar to JSX, giving you IDE autocomplete and ensuring all components are properly bundled. - -### Setup - -```javascript -// vite.config.js -import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; - -export default { - plugins: [openScriptComponentPlugin()], -}; -``` - -### Usage - -```javascript -// src/main.js -import { app } from "modular-openscriptjs"; -import "virtual:openscript-components"; // Auto-imports all components! - -const h = app("h"); - -// IDE will autocomplete component names! -// Components are automatically imported and bundled! -h.TodoList({ parent: document.body }); -h.Header({ parent: document.body }); -``` - -**Benefits:** - -- ✅ IDE autocomplete for `h.ComponentName` -- ✅ Automatic component imports (no manual imports needed) -- ✅ TypeScript support with generated `.d.ts` files -- ✅ Proper Vite bundling -- ✅ Hot Module Replacement (HMR) - -See [Component Auto-Import Guide](./docs/COMPONENT_AUTO_IMPORT.md) for details. diff --git a/docs/notes.md b/docs/notes.md new file mode 100644 index 0000000..874c922 --- /dev/null +++ b/docs/notes.md @@ -0,0 +1 @@ +- Putting a stage on the root component of the app can result in a memory leak that keeps at least double of the dom nodes currently rendered. It's best to have a second level component on which the states can are passed if at all there must be global states. \ No newline at end of file diff --git a/package.json b/package.json index 37a514c..7a646fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "2.0.0", + "version": "2.0.3", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/component/Component.js b/src/component/Component.js index b6b4a07..78ae7c7 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -522,7 +522,7 @@ export default class Component { let Cls = class extends Component { constructor() { super(); - this.name = `anonym-${id}`; + this.name = `anonym${id}`; this.isAnonymous = true; } diff --git a/src/core/Runner.js b/src/core/Runner.js index 25ae4aa..b9a238f 100644 --- a/src/core/Runner.js +++ b/src/core/Runner.js @@ -35,29 +35,40 @@ export default class Runner { let c = cls[i]; let instance; const classKey = this.getClassKey(c); + const instanceName = c.name; if (!this.isClass(c)) { - instance = new Component(c.name); - } else { - if (registrations[classKey] === "ongoing") { - continue; - } + let functionClass = class extends Component { + constructor() { + super(); - if (container.has(classKey)) { - instance = container.resolve(classKey); - if (instance.__ojsRegistered) { - continue; + this.name = instanceName; } - } else { - instance = new c(); - container.singleton(classKey, () => instance, []); - registrations[classKey] = "ongoing"; + }; + + functionClass.prototype.render = c; + + c = functionClass; + } + + if (registrations[classKey] === "ongoing") { + continue; + } + + if (container.has(classKey)) { + instance = container.resolve(classKey); + if (instance.__ojsRegistered) { + continue; } + } else { + instance = new c(); + container.singleton(classKey, () => instance, []); + registrations[classKey] = "ongoing"; } if (instance instanceof Component) { registrations[classKey] = "completed"; - h.registerComponent(c.name, c); + h.registerComponent(instanceName, c); } else if (instance instanceof Mediator || instance instanceof Listener) { await instance.register(); registrations[classKey] = "completed"; diff --git a/src/core/State.js b/src/core/State.js index 8cf65e3..50a6a96 100644 --- a/src/core/State.js +++ b/src/core/State.js @@ -57,7 +57,11 @@ export default class State { ) { if (typeof listener === "string") { let uid = listener.split("-")[1]; - listener = container.resolve("repository").findComponent(Number(uid)); + listener = container.resolve("repository").findComponent(uid); + + if (!listener) { + return null; + } } this.$__listeners__.set(`component-${listener.id}`, listener); From 672af33b4c03078437d6b9648d6f169199229158 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Tue, 20 Jan 2026 21:14:01 +0300 Subject: [PATCH 33/46] worked on unmount to clean up nodes before removing --- package.json | 2 +- src/component/Component.js | 23 +++++++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 7a646fe..7e3d215 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "2.0.3", + "version": "2.0.4", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/component/Component.js b/src/component/Component.js index 78ae7c7..c42a7c7 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -5,8 +5,10 @@ import State from "../core/State.js"; import { container } from "../core/Container.js"; import { cleanupDisconnectedComponents, + destroyNodeDeep, getOjsChildren, } from "../utils/helpers.js"; +import DOM from "../utils/DOM.js"; /** * Base Component Class @@ -145,7 +147,7 @@ export default class Component { getDeclaredListeners() { if (this.__ojsRegistered) { console.warn( - `Component "component:${this.id}" is already registered. Skipping duplicate registration.` + `Component "component:${this.id}" is already registered. Skipping duplicate registration.`, ); return; } @@ -200,12 +202,22 @@ export default class Component { * Deletes all the component's markup from the DOM */ unmount() { + + /** + * Clean up the dom based on the developer's logic. + */ + this.cleanUp(); + + /** + * @var {NodeList} all + */ let all = this.markup(); for (let elem of all) { + destroyNodeDeep(elem); elem.remove(); } - + this.releaseMemory(); this.unmounted = true; @@ -249,7 +261,7 @@ export default class Component { if (!parent) parent = h.dom; return parent.querySelectorAll( - `ojs-${this.kebab(this.name)}[uid="${this.id}"]` + `ojs-${this.kebab(this.name)}[uid="${this.id}"]`, ); } @@ -272,7 +284,6 @@ export default class Component { releaseMemory() { container.resolve("repository").removeComponent(this.id); container.resolve("repository").removeComponentArgs(this.id); - this.cleanUp(); this.emitter.clear(); @@ -403,7 +414,7 @@ export default class Component { h.dom.querySelectorAll( `ojs-${this.kebab(this.name)}[uid="${ this.id - }"][s-${stateId}="${stateId}"]` + }"][s-${stateId}="${stateId}"]`, ) ?? []; current.forEach((e) => { @@ -491,7 +502,7 @@ export default class Component { const rendered = h[`ojs-${this.kebab(this.name)}`]( attr, markup, - cAttributes + cAttributes, ); if (reconcileParent && parent) { From f30ddd1ab1ff525d25a3a84596703ba0fda328e2 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Wed, 21 Jan 2026 08:12:25 +0300 Subject: [PATCH 34/46] fixed product bug --- bin/create-ojs-app | 2 +- build/vite-plugin-openscript.js | 268 ++++++++++++-------- docs/notes.md | 72 +++++- docs/setting-up.md | 362 ++++++++++++++++++++++++++++ examples/advanced-features.js | 78 ------ examples/basic-app/contexts.js | 87 ------- examples/basic-app/events.js | 69 ------ examples/basic-app/helpers.js | 71 ------ examples/basic-app/index.html | 21 -- examples/basic-app/index.js | 54 ----- examples/basic-app/pages/TodoApp.js | 187 -------------- examples/basic-app/routes.js | 74 ------ examples/basic-usage.js | 28 --- examples/component-example.js | 28 --- examples/context-state-example.js | 197 --------------- examples/event-handling.js | 147 ----------- examples/full-application.js | 339 -------------------------- examples/state-example.js | 357 --------------------------- examples/vite.config.example.js | 24 -- package.json | 2 +- src/component/Component.js | 1 - src/core/Runner.js | 2 +- test_output.txt | Bin 16318 -> 0 bytes test_regex.js | 55 ----- 24 files changed, 597 insertions(+), 1928 deletions(-) create mode 100644 docs/setting-up.md delete mode 100644 examples/advanced-features.js delete mode 100644 examples/basic-app/contexts.js delete mode 100644 examples/basic-app/events.js delete mode 100644 examples/basic-app/helpers.js delete mode 100644 examples/basic-app/index.html delete mode 100644 examples/basic-app/index.js delete mode 100644 examples/basic-app/pages/TodoApp.js delete mode 100644 examples/basic-app/routes.js delete mode 100644 examples/basic-usage.js delete mode 100644 examples/component-example.js delete mode 100644 examples/context-state-example.js delete mode 100644 examples/event-handling.js delete mode 100644 examples/full-application.js delete mode 100644 examples/state-example.js delete mode 100644 examples/vite.config.example.js delete mode 100644 test_output.txt delete mode 100644 test_regex.js diff --git a/bin/create-ojs-app b/bin/create-ojs-app index 2156df3..57f6c5d 100644 --- a/bin/create-ojs-app +++ b/bin/create-ojs-app @@ -85,7 +85,7 @@ async function createProject(projectName, template = "basic") { preview: "vite preview", }, dependencies: { - "modular-openscriptjs": "^1.0.0", + "modular-openscriptjs": "latest", }, devDependencies: { vite: "^5.0.7", diff --git a/build/vite-plugin-openscript.js b/build/vite-plugin-openscript.js index ee3f83d..a5078fa 100644 --- a/build/vite-plugin-openscript.js +++ b/build/vite-plugin-openscript.js @@ -8,16 +8,10 @@ import MagicString from "magic-string"; * Automatically discovers components and provides IDE autocomplete + bundling */ export function openScriptComponentPlugin(options = {}) { - const { - componentsDir = "src/components", - autoRegister = true, - generateTypes = true, - } = options; + const { componentsDir = "src/components", generateTypes = true } = options; let config; let components = []; - const virtualModuleId = "virtual:openscript-components"; - const resolvedVirtualModuleId = "\0" + virtualModuleId; return { name: "openscript-component-plugin", @@ -32,7 +26,7 @@ export function openScriptComponentPlugin(options = {}) { if (!fs.existsSync(componentsPath)) { console.warn( - `[OpenScript] Components directory not found: ${componentsPath}` + `[OpenScript] Components directory not found: ${componentsPath}`, ); return; } @@ -42,7 +36,7 @@ export function openScriptComponentPlugin(options = {}) { console.log( `[OpenScript] Found ${components.length} components:`, - components.map((c) => c.name).join(", ") + components.map((c) => c.name).join(", "), ); // Generate TypeScript definitions if enabled @@ -51,83 +45,185 @@ export function openScriptComponentPlugin(options = {}) { } }, - resolveId(id) { - if (id === virtualModuleId) { - return resolvedVirtualModuleId; - } - }, - - load(id) { - if (id === resolvedVirtualModuleId) { - // Generate virtual module that imports all components - return generateVirtualModule( - config.root, - componentsDir, - components, - autoRegister - ); - } - }, - transform(code, id) { + // Normalize path for Windows compatibility + const normalizedId = normalizePath(id); + // Only transform files in components directory - if (!id.includes(componentsDir) || !id.endsWith(".js")) return; + if ( + !normalizedId.includes(componentsDir) || + !normalizedId.endsWith(".js") + ) + return; - // Find class definition - const classMatch = code.match(/class\s+(\w+)\s+extends\s+Component/); - if (!classMatch) return; + const s = new MagicString(code); + let hasChanged = false; + + // 1. Check for Functional Components: function MyComp(...) { ... } + // Regex matches: 1=export_modifier, 2=Name, 3=Args + const funcRegex = + /(export\s+default\s+|export\s+)?function\s+([A-Z]\w*)\s*\(([^)]*)\)\s*\{/g; + let match; + + // We need to handle multiple components in one file, though rare for default exports + // But let's loop to be safe and use a while loop with exec + while ((match = funcRegex.exec(code)) !== null) { + const [fullMatch, exportModifier = "", name, args] = match; + const start = match.index; + const bodyStart = start + fullMatch.length - 1; // pointing to { + + // Find closing brace + let braceCount = 1; + let end = -1; + let i = bodyStart + 1; + + while (i < code.length) { + if (code[i] === "{") braceCount++; + else if (code[i] === "}") braceCount--; + + if (braceCount === 0) { + end = i; + break; + } + i++; + } - const className = classMatch[1]; + if (end !== -1) { + // Found the component body + hasChanged = true; + + // Replace header + // "export default function Name(args) {" + // -> + // "export default class Name extends Component { constructor() { super(); this.name = "Name"; } render(args) {" + const replacement = `${exportModifier}class ${name} extends Component { + constructor() { + super(); + this.name = "${name}"; + } - // If code already sets this.name explicitly, skip (simple check) - if (code.includes(`this.name = "${className}"`)) return; + render(${args}) {`; - const s = new MagicString(code); + s.overwrite(start, bodyStart + 1, replacement); - if (code.includes("constructor")) { - // Inject after super() - const superMatch = code.match(/(super\s*\([^)]*\)\s*;?)/); - if (superMatch) { - const index = superMatch.index + superMatch[0].length; - s.appendRight( - index, - `\n if (!this.name) this.name = "${className}";` - ); + // Append closing brace for the class + s.appendRight(end + 1, "\n}"); } - } else { - // No constructor, inject one - const classDef = classMatch[0]; - const openBraceIndex = code.indexOf("{", code.indexOf(classDef)); + } - if (openBraceIndex !== -1) { + // 2. Existing logic: Class Component name injection + // Only run this if we haven't just converted it (though regex below checks for class) + // If we converted, we already injected the name. + // But there might be other classes in the file. + + // Note: If we just converted, s.toString() inside loop? + // MagicString handles edits on original string. + // But our regex 'code.match' works on original code. + // If we have mixed content, we should be careful. + // The original logic scanned for "class ... extends Component". + // Our new logic creates that string in output, but original code didn't have it. + // So checks against 'code' for class will only find ORIGINAL classes. + + const classRegex = /class\s+(\w+)\s+extends\s+Component/g; + while ((match = classRegex.exec(code)) !== null) { + const className = match[1]; + + // Check if this class already has name set in ORIGINAL code + // (If we synthesized it, we don't need to do this, and regex won't find synthesized one) + const classStart = match.index; + // Simple check range for existing name assignment is hard with regex loop + // But we can check globally if the code has it for this class + if (code.includes(`this.name = "${className}"`)) continue; + + // Find constructor or inject it + // We need to look inside the class body. + // Find the opening brace for this class + const openBraceIndex = code.indexOf("{", classStart); + if (openBraceIndex === -1) continue; + + // Check if constructor exists within reasonable distance/scope? + // This is tricky with simple string searching on the whole file. + // But let's assume standard structure or rely on previous logic's robustness + // The previous logic used 'if (code.includes("constructor"))' which is global! + // That was capable of false positives if multiple classes existed or comments. + // But let's stick to the previous logic style for consistency but apply it carefully. + + // Actually, since I'm rewriting the transform function, I can try to improve it slightly + // or just keep it as is for existing classes. + + // Given constraints, I will preserve the logic's INTENT: + // Ensure `this.name` is set. + + // For the *functional* transformation I just added, I *know* I added the constructor. + // So I don't need to process those. + // So I only process matches from `classRegex` on the `code` string. + // Since `code` is original source, it won't include my functional-to-class transforms. + // So I am only processing originally-written classes. Perfect. + + // Re-implementing existing logic for original classes: + + // Try to find constructor in the class body? + // We'll search for `constructor` keyword after class def. + const nextClassIndex = code.indexOf("class ", openBraceIndex); + const searchEnd = nextClassIndex === -1 ? code.length : nextClassIndex; + const constructorMatch = code + .substring(openBraceIndex, searchEnd) + .match(/constructor\s*\(/); + + if (constructorMatch) { + // Constructor exists + // Find super() call relative to class start + const constructorAbsIndex = openBraceIndex + constructorMatch.index; + // Search for super() after constructor + const superMatch = code + .substring(constructorAbsIndex, searchEnd) + .match(/super\s*\([^)]*\)\s*;?/); + if (superMatch) { + const insertAt = + constructorAbsIndex + superMatch.index + superMatch[0].length; + s.appendRight( + insertAt, + `\n if (!this.name) this.name = "${className}";`, + ); + hasChanged = true; + } + } else { + // No constructor, inject one at start of class s.appendRight( openBraceIndex + 1, - `\n constructor() { super(); this.name = "${className}"; }` + `\n constructor() { super(); this.name = "${className}"; }`, ); + hasChanged = true; } } - if (s.hasChanged()) { + // 3. Inject Component import if needed + // If we did any transformation (functional or existing class), we might need Component. + // Especially for functional -> class. + if (hasChanged) { + // Check if Component is imported + // Matches: import { Component } ... or import ... Component ... + // Simple check: + if (!code.includes("import") || !code.match(/import\s+.*Component/)) { + // Need to import Component. + // Check if existing import from "modular-openscriptjs" exists + if (code.includes("modular-openscriptjs")) { + // Try to add to existing import? Hard with regex. + // Easier to just add a new line. + s.prepend(`import { Component } from "modular-openscriptjs";\n`); + } else { + s.prepend(`import { Component } from "modular-openscriptjs";\n`); + } + } + } + + if (hasChanged) { return { code: s.toString(), map: s.generateMap({ source: id, includeContent: true }), }; } }, - - // HMR support - handleHotUpdate({ file, server }) { - if (file.includes(componentsDir)) { - // Reload virtual module when components change - const module = server.moduleGraph.getModuleById( - resolvedVirtualModuleId - ); - if (module) { - server.moduleGraph.invalidateModule(module); - return [module]; - } - } - }, }; } @@ -217,45 +313,3 @@ export {}; fs.writeFileSync(dtsPath, content, "utf-8"); console.log(`[OpenScript] Generated type definitions: ${dtsPath}`); } - -/** - * Generate virtual module content that imports and registers all components - */ -function generateVirtualModule(root, componentsDir, components, autoRegister) { - const imports = components - .map((c) => { - const absolutePath = normalizePath( - path.resolve(root, componentsDir, c.path) - ); - return `import ${c.name} from '${absolutePath}';`; - }) - .join("\n"); - - const exports = components.map((c) => c.name).join(", "); - - const registration = autoRegister - ? ` -// Auto-register all components -const components = { ${exports} }; - -export async function registerAllComponents() { - for (const [name, Component] of Object.entries(components)) { - const instance = new Component(); - await instance.mount(); - } -} -` - : ""; - - return `// Auto-generated by OpenScript Component Plugin -${imports} - -${registration} - -// Export all components for manual use -export { ${exports} }; - -// Export component registry -export default { ${exports} }; -`; -} diff --git a/docs/notes.md b/docs/notes.md index 874c922..a8bdd66 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -1 +1,71 @@ -- Putting a stage on the root component of the app can result in a memory leak that keeps at least double of the dom nodes currently rendered. It's best to have a second level component on which the states can are passed if at all there must be global states. \ No newline at end of file +# Best practices + +- Putting a state on the root component of the app can result in a memory leak that keeps at least double of the dom nodes currently rendered. It's best to have a second level component on which the states can are passed if at all there must be global states. + +- All components should be placed in a separate folder, e.g. `src/components` for easy building. Mixing components with other files can lead to build errors. + +- If a component doesn't directly extend the `Component` class, it should explicitly have it's name attribute set, like `name = "MyComponent";` in the class. This is required for the `h` (Markup Engine) object to register the component. + +- For functional components, ensure the name of the component starts with a capital letter, e.g. `function MyComponent(...){...}`. + +- When setting up a new project, separate configuration into files. For example, `ojs.config.js` to configure the router and other necessary ojs objects, `contexts.js` to configure contexts, `routes.js` to configure routes, etc. and finally include them into your `index.js` or `main.js` file. This makes it easier to manage and update the configuration. + +- Place mediators into a separate folder, e.g. `src/mediators` for easy building. Mixing mediators with other files can lead to build errors. + +- Mediators should be stateless and only have event handlers to handle application logic. + +- Use contexts to keep global states. Contexts are objects that can be used to share data between components. They are initialized in the `contexts.js` file and can be used in any component by importing them from the `contexts.js` file. + +- The `app()` function is the IoC container. You can use it to register and get instances that should be shared around the application. + +# Router Notes + +The `Router` class in OpenScript handles client-side routing. + +## Initialization & usage + +- **`router.listen()`**: This must be called to start listening to route changes and to handle the current initial route. + - _Crucial_: It should be called in the entry point (e.g., `main.js`) **AFTER** all routes and services have been configured. Calling it prematurely might lead to 404s or unhandled routes if the route definitions haven't been loaded yet. + - It attaches a `popstate` listener to handle browser back/forward buttons. +- **Dependencies**: The Router uses the `broker` to emit events (`ojs:beforeRouteChange`, `ojs:routeChanged`). + +## Defining Routes + +- **`router.on(path, action, name)`**: Registers a route. + - `path`: The URL path pattern. Supports dynamic segments using `{paramName}`. + - `action`: The function to execute when the route matches. + - `name` (optional): A unique name for the route, allowing for reverse routing (generating URLs by name). +- **`router.orOn(paths, action, names)`**: Registers multiple paths that point to the same action. +- **`router.default(action)`**: Sets a catch-all action for when no route matches (404). Default behavior is `alert("404 File Not Found")`. +- **`router.prefix(name).group(callback)`**: Groups routes under a common prefix. + - _Note_: The `callback` is executed immediately, and routes defined inside will have the prefix prepended. + +## Navigation + +- **`router.to(path, qs)`**: Navigates to a path or named route. + - `path`: Can be a raw URL path segments or a registered route name. + - `qs`: Query string parameters object. + - It pushes a new state to `window.history` and calls `listen()` to trigger the route action. +- **`router.toName(routeName, params)`**: Navigates to a named route, replacing dynamic segments with values from `params`. +- **`router.back()`**: Navigate back in history. +- **`router.forward()`**: Navigate forward in history. +- **`router.redirect(to)`**: Performs a hard browser redirect (modifies `window.location.href`). + +## Route Matching & Parameters + +- **Dynamic Segments**: Routes can have `{param}` segments (e.g., `/user/{id}`). +- **Wildcards**: `{...}` matches any segment, treated effectively as `*`. +- **`router.params`**: After matching a route, this object contains the values of dynamic segments. +- **`router.qs`**: Contains `URLSearchParams` for the current query string. +- **`router.current()`**: Returns the current path. +- **`router.is(nameOrRoute)`**: Checks if the current route matches a specific name or path. useful for setting active states on navigation links. + +## Configuration + +- **`router.runtimePrefix(prefix)`**: Sets a prefix that is stripped or added handling runtime path resolutions (useful for sub-directory deployments). +- **`router.basePath(path)`**: Sets a base path for the router. + +## Internal Mechanics + +- The router uses a nested `Map` structure for efficient route matching (Trie-like structure where each path segment is a key). +- It handles `popstate` events automatically once `listen()` is called. diff --git a/docs/setting-up.md b/docs/setting-up.md new file mode 100644 index 0000000..da38b52 --- /dev/null +++ b/docs/setting-up.md @@ -0,0 +1,362 @@ +# Setting Up OpenScript + +This guide will walk you through the process of setting up a new OpenScript project. We will cover installation, creating the entry point, configuring Vite, and setting up the OpenScript configuration file. + +## 1. Installation + +First, install the `modular-openscriptjs` package using npm: + +```bash +npm install modular-openscriptjs +``` + +## 2. Create Entry Point + +First, create an `index.html` file in your project root. This will be the shell of your application. + +```html + + + + + + My OpenScript App + + +
+ + + +``` + +Next, create the `src/main.js` file. This file initializes the application, sets up routes, and starts the router. + +```javascript +import "./ojs.config.js"; // Import configuration to ensure services are ready +import { app } from "modular-openscriptjs"; + +// Import your route setup function (we'll define this later) +// import { setupRoutes } from "./routes.js"; + +async function init() { + // 1. Configure services (if not done in top-level of config) but usually config is imported. + + // 2. Setup your application context (like getting the root element) + const rootElement = document.getElementById("app-root"); + + // 3. Setup Routes + // setupRoutes(rootElement); + + // 4. Start the Router + const router = app("router"); + router.listen(); +} + +init(); +``` + +_Note: In visual applications, you typically define routes that render components into the `rootElement`._ + +## 3. Install Vite and Configure Plugin + +OpenScript uses Vite for bundling and development. Install Vite and the necessary plugins: + +```bash +npm install vite --save-dev +``` + +Next, create a `vite.config.js` file in your project root and configure the OpenScript plugin. This plugin handles necessary transformations, such as component auto-discovery. + +```javascript +import { defineConfig } from "vite"; +import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; + +export default defineConfig({ + plugins: [ + openScriptComponentPlugin({ + // Optional: Configure components directory if different from 'src/components' + // componentsDir: 'src/components' + }), + ], +}); +``` + +## 4. OpenScript Configuration + +Create an `ojs.config.js` file in your project root. This file is where you configure the core services of OpenScript, such as the Router and Broker. + +```javascript +import { app } from "modular-openscriptjs"; +import { appEvents } from "./events.js"; // We will create this in the next section + +/*---------------------------------- + | Do OpenScript Configurations Here + |---------------------------------- +*/ + +const router = app("router"); +const broker = app("broker"); + +export function configureApp() { + /*----------------------------------- + | Set the global runtime prefix. + | This prefix will be appended + | to every path before resolution. + | So ensure when defining routes, + | you have it as the main prefix. + |------------------------------------ +*/ + router.runtimePrefix(""); + + /**---------------------------------- + * + * Set the default route path here + * ---------------------------------- + */ + router.basePath(""); + + /*-------------------------------- + | Set the logs clearing interval + | for the broker to remove stale + | events. (milliseconds) + |-------------------------------- +*/ + broker.CLEAR_LOGS_AFTER = 30000; + + /*-------------------------------- + | Set how old an event must be + | to be deleted from the broker's + | event log during logs clearing + |-------------------------------- +*/ + broker.TIME_TO_GC = 10000; + + /*------------------------------------------- + | Start the garbage + | collector for the broker + |------------------------------------------- +*/ + broker.removeStaleEvents(); + + /*------------------------------------------ + | Should the broker display events + | in the console as they are fired + |------------------------------------------ +*/ + if (/^(127\.0\.0\.1|localhost|.*\.test)$/.test(router.url().hostname)) { + broker.withLogs(false); // Enable logs for development + } + + /** + * --------------------------------------------- + * Should the broker require events registration. + * This ensures that only registered events + * can be listened to and fire by the broker. + * --------------------------------------------- + */ + broker.requireEventsRegistration(true); + + /** + * --------------------------------------------- + * Register events with the broker + * --------------------------------------------- + */ + + broker.registerEvents(appEvents); + + /** + * --------------------------------------------- + * Register core services in IoC container + * --------------------------------------------- + */ + app().value("appEvents", appEvents); +} + +// execute configuration +configureApp(); +``` + +> **Note**: In the configuration above, we are using `appEvents` imported from `events.js`. We will cover the creation of `events.js` and how to handle events in the subsequent sections. + +## 5. Define Application Events + +OpenScript uses a centralized event broker. It's best practice to define all your application events in a single file, typically `events.js` (or `src/events.js`). + +If you configured `broker.requireEventsRegistration(true)` in your `ojs.config.js`, only events defined here and registered will be allowed. + +Create a `src/events.js` file: + +```javascript +/** + * Application Events + * Structure: Nested object where keys become namespaced event names + * Example: app.started becomes "app:started" + * todo.added -> "todo:added" + */ +export const appEvents = { + app: { + started: true, + ready: true, + }, + + // Example for a Todo App + todo: { + added: true, + deleted: true, + completed: true, + + // Nested events + needs: { + refresh: true, + }, + }, + + ui: { + modal: { + opened: true, + closed: true, + }, + }, +}; +``` + +This structure allows you to use `appEvents.todo.added` to refer to the event in your code, providing strict typing and avoiding magic strings. + +## 6. Configure Contexts + +Contexts are used to manage state and share data across your application. Create an `ojs.contexts.js` file (or `src/ojs.contexts.js`) to initialize them. + +```javascript +import { context, putContext, app } from "modular-openscriptjs"; + +// 1. Register Context Keys +// This reserves the keys for your contexts. +// The second argument is a provider name (can be arbitrary for simple apps). +putContext(["global", "todo"], "AppContext"); + +// 2. Export Context Instances for usage in other files +export const gc = context("global"); +export const tc = context("todo"); + +// 3. Setup Function to Initialize States +export function setupContexts() { + // Initialize Global Context + gc.states({ + appName: "My OpenScript App", + isAuthenticated: false, + user: null, + }); + + // Initialize Todo Context + tc.states({ + todos: [], + filter: "all", + }); + + // Add listeners if needed + tc.todos.listener((state) => { + console.log("Todos updated:", state.value); + }); + + // 4. Register in IoC Container (Optional but recommended) + // This allows you to retrieve contexts using app("gc") anywhere. + app().value("gc", gc); + app().value("tc", tc); + + console.log("Contexts initialized"); +} +``` + +Don't forget to import and call `setupContexts()` in your `main.js`: + +```javascript +// in main.js +import "./ojs.config.js"; // 1. Configuration first +import { setupContexts } from "./ojs.contexts.js"; // 2. Then Contexts + +// ... other imports + +setupContexts(); // Initialize contexts before mounting app +``` + +## 7. Configure Routes + +Create an `ojs.routes.js` file (or `src/ojs.routes.js`) to define your application's routes. + +This file typically handles two things: + +1. Importing your page components. +2. Defining the routes in the router. +3. Defining a render helper to mount components into the root element. + +```javascript +import { app, ojs } from "modular-openscriptjs"; +import App from "./components/App.js"; // Your main layout component +import HomePage from "./components/HomePage.js"; + +// Register components with the Markup Engine if they aren't auto-discovered +ojs(App, HomePage); + +export function setupRoutes() { + const router = app("router"); + const h = app("h"); + + // Get the root element (assuming it was set in Global Context or we get it directly) + const rootElement = document.getElementById("app-root"); + + /** + * Helper to render a component to the root element. + * We use h.App (or your layout component) to wrap the page. + * + * @param {Component} component - The page component to render. + */ + const appRender = (component) => { + // h.App refers to the App component registered above. + // 'parent' option tells the engine where to render this component. + return h.App(component, { + parent: rootElement, + resetParent: true, // Clear the parent content before rendering + reconcileParent: true, // Efficiently update the DOM if possible + }); + }; + + // Define Routes + + // Default route (redirects to /home) + router.default(() => router.to("home")); + + router.on( + "/", + () => { + appRender(h.HomePage()); + }, + "home", + ); + + // Example of another route + // router.on("/about", () => appRender(h.AboutPage()), "about"); + + console.log("Routes configured"); +} +``` + +Now, update your `main.js` to include the routes: + +```javascript +// in main.js +import "./ojs.config.js"; +import { setupContexts } from "./ojs.contexts.js"; +import { setupRoutes } from "./ojs.routes.js"; // Import routes setup + +// ... + +setupContexts(); + +// Setup routes before starting the router +setupRoutes(); + +const router = app("router"); +router.listen(); +``` + +You are now set up with the basic structure of an OpenScript application! diff --git a/examples/advanced-features.js b/examples/advanced-features.js deleted file mode 100644 index 711adbd..0000000 --- a/examples/advanced-features.js +++ /dev/null @@ -1,78 +0,0 @@ -import { Component, app, state, putContext, context } from "../index.js"; - -const h = app("h"); - -// 1. Fragments Example -class FragmentComponent extends Component { - render(...args) { - // h.$ or h._ creates a document fragment - // This allows returning multiple elements without a parent wrapper - return h.$( - h.h3("Fragment Header"), - h.p("This content is inside a fragment."), - h.p("No extra div wrapper is added to the DOM.") - ); - } -} - -// 2. State Management Example -const counter = state(0); - -class CounterComponent extends Component { - render(...args) { - // Pass the state to the component to auto-subscribe - // The component will re-render when 'counter' changes - return h.div( - h.h3(`Count: ${counter.value}`), - h.button({ onclick: () => counter.value++ }, "Increment"), - ...args - ); - } -} - -// 3. Context Example -// Define a context (normally this would be in a separate file) -class ThemeContext { - constructor() { - this.theme = state("light"); - } - - toggle() { - this.theme.value = this.theme.value === "light" ? "dark" : "light"; - } -} - -// Register the context -// putContext(referenceName, qualifiedName) -// Since we are not loading from a file here, we just register it manually for this example -// In a real app, you might use: putContext("Theme", "contexts.ThemeContext") -const themeCtx = new ThemeContext(); -// Manually putting it in the provider for this example using the IoC container -app("contextProvider").map.set("Theme", themeCtx); - -class ThemedComponent extends Component { - constructor() { - super(); - // Access the context - this.themeContext = context("Theme"); - } - - render(...args) { - const currentTheme = this.themeContext.theme.value; - - return h.div( - { - style: `background-color: ${ - currentTheme === "light" ? "#fff" : "#333" - }; color: ${ - currentTheme === "light" ? "#000" : "#fff" - }; padding: 20px;`, - }, - h.h3(`Current Theme: ${currentTheme}`), - h.button({ onclick: () => this.themeContext.toggle() }, "Toggle Theme"), - ...args - ); - } -} - -export { FragmentComponent, CounterComponent, ThemedComponent }; diff --git a/examples/basic-app/contexts.js b/examples/basic-app/contexts.js deleted file mode 100644 index 05e3413..0000000 --- a/examples/basic-app/contexts.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Context and State Initialization for Todo App - * Global state management following OpenScript best practices - */ - -import { context, dom, putContext } from "../../index.js"; -import { saveTodosToLocalStorage } from "./helpers.js"; - -// ============================================ -// 1. REGISTER CONTEXTS -// ============================================ - -// Register contexts (creates Context instances) -putContext(["global", "todo", "ui"], "TodoAppContext"); - -// ============================================ -// 2. GET CONTEXT REFERENCES -// ============================================ - -/** - * Global Context - Application-wide state - * @type {Context} - */ -export const gc = context("global"); - -/** - * Todo Context - Todo items and filtering - * @type {Context} - */ -export const tc = context("todo"); - -/** - * UI Context - UI state (modals, loading, etc.) - * @type {Context} - */ -export const uic = context("ui"); - -// ============================================ -// 3. INITIALIZE GLOBAL CONTEXT STATES -// ============================================ - -gc.states({ - appName: "Todo List App", - version: "1.0.0", - isInitialized: false, -}); - -// Set root element for global context -gc.rootElement = dom.id("app-root"); - -// ============================================ -// 4. INITIALIZE TODO CONTEXT STATES -// ============================================ - -tc.states({ - todos: [], // Array of todo items - filter: "all", // "all" | "active" | "completed" - sortBy: "createdAt", // "createdAt" | "text" | "priority" - nextId: 1, -}); - -// ============================================ -// 5. INITIALIZE UI CONTEXT STATES -// ============================================ - -uic.states({ - loading: false, - editingTodoId: null, - showDeleteConfirm: false, - todoToDelete: null, -}); - -// ============================================ -// 6. STATE LISTENERS -// ============================================ - -// Listen to todo changes -tc.todos.listener((todosState) => { - console.log(`Todos updated: ${todosState.value.length} todos`); - // Save to localStorage - saveTodosToLocalStorage(todosState.value); -}); - -// Listen to filter changes -tc.filter.listener((filterState) => { - console.log(`Filter changed to: ${filterState.value}`); -}); \ No newline at end of file diff --git a/examples/basic-app/events.js b/examples/basic-app/events.js deleted file mode 100644 index 2b88264..0000000 --- a/examples/basic-app/events.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Event Definitions for Basic App - * Centralized event catalog following OpenScript best practices - */ - -import { app } from "../../index.js"; - -const broker = app("broker"); - -/** - * Application Events - * Structure: Nested object where keys become namespaced event names - * Example: app.started becomes "app:started" - */ -export const $e = { - app: { - started: true, - ready: true, - }, - - todo: { - added: true, - updated: true, - deleted: true, - completed: true, - uncompleted: true, - - needs: { - add: true, - update: true, - delete: true, - toggle: true, - filter: true, - }, - - has: { - addError: true, - updateError: true, - deleteError: true, - list: true, - }, - }, - - filter: { - changed: true, - cleared: true, - - needs: { - apply: true, - clear: true, - }, - }, - - ui: { - needs: { - modal: true, - confirm: true, - toast: true, - }, - - modal: { - opened: true, - closed: true, - }, - }, -}; - -// Register all events with the broker -broker.registerEvents($e); diff --git a/examples/basic-app/helpers.js b/examples/basic-app/helpers.js deleted file mode 100644 index c55dc20..0000000 --- a/examples/basic-app/helpers.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Helper Functions for Todo App - * Utility functions for working with todos and app state - */ - -import { tc, uic } from "./contexts.js"; - -/** - * Get filtered todos based on current filter - * @returns {Array} Filtered todos - */ -export function getFilteredTodos() { - const todos = tc.todos.value; - const filter = tc.filter.value; - - switch (filter) { - case "active": - return todos.filter(t => !t.completed); - case "completed": - return todos.filter(t => t.completed); - default: - return todos; - } -} - -/** - * Get todo statistics - * @returns {Object} Stats object with total, completed, and active counts - */ -export function getTodoStats() { - const todos = tc.todos.value; - return { - total: todos.length, - completed: todos.filter(t => t.completed).length, - active: todos.filter(t => !t.completed).length - }; -} - -/** - * Save todos to localStorage - * @param {Array} todos - Array of todo items - */ -export function saveTodosToLocalStorage(todos) { - try { - localStorage.setItem('openscript-todos', JSON.stringify(todos)); - } catch (e) { - console.error('Failed to save todos:', e); - } -} - -/** - * Load todos from localStorage - * @returns {Array} Loaded todos or empty array - */ -export function loadTodosFromLocalStorage() { - try { - const saved = localStorage.getItem('openscript-todos'); - return saved ? JSON.parse(saved) : []; - } catch (e) { - console.error('Failed to load todos:', e); - return []; - } -} - -/** - * Set loading state - * @param {boolean} isLoading - Loading state - */ -export function setLoading(isLoading) { - uic.loading.value = isLoading; -} diff --git a/examples/basic-app/index.html b/examples/basic-app/index.html deleted file mode 100644 index 3fd1de3..0000000 --- a/examples/basic-app/index.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - Todo App - OpenScript - - - - - - - - - -
- - - - \ No newline at end of file diff --git a/examples/basic-app/index.js b/examples/basic-app/index.js deleted file mode 100644 index 6a637ba..0000000 --- a/examples/basic-app/index.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Main Entry Point for Todo App - * Initializes the application and starts routing - */ - -// Import event definitions and register them -import { $e } from "./events.js"; - -// Import contexts (this initializes all states and listeners) -import { gc, tc, uic } from "./contexts.js"; - -// Import helper functions -import { loadTodosFromLocalStorage } from "./helpers.js"; - -// Import routes (this registers all routes) -import "./routes.js"; - -// Import OpenScript utilities -import { app } from "../../index.js"; - -const broker = app("broker"); -const router = app("router"); - -// ============================================ -// APPLICATION INITIALIZATION -// ============================================ - -console.log("🚀 Initializing Todo App..."); - -// Load saved todos from localStorage -const savedTodos = loadTodosFromLocalStorage(); -if (savedTodos.length > 0) { - tc.todos.value = savedTodos; - // Update nextId based on loaded todos - const maxId = Math.max(...savedTodos.map((t) => t.id || 0)); - tc.nextId = maxId + 1; -} - -// Emit app started event -broker.send($e.app.started); - -// Mark app as initialized -gc.isInitialized.value = true; - -console.log("✓ Todo App initialized successfully"); - -// ============================================ -// START ROUTER -// ============================================ - -// Start listening to route changes -router.listen(); - -console.log("✓ Router started"); diff --git a/examples/basic-app/pages/TodoApp.js b/examples/basic-app/pages/TodoApp.js deleted file mode 100644 index d26d0eb..0000000 --- a/examples/basic-app/pages/TodoApp.js +++ /dev/null @@ -1,187 +0,0 @@ -/** - * TodoApp - Root Layout Component - * Main page layout for the todo application - */ - -import { Component, app } from "../../../index.js"; -import { gc, tc } from "../contexts.js"; - -const h = app("h"); - -export default class TodoApp extends Component { - render(...args) { - return h.div( - { class: "container py-5" }, - - // Header - h.div( - { class: "row mb-4" }, - h.div( - { class: "col-12 text-center" }, - h.h1( - { class: "display-4 mb-2" }, - h.i({ class: "fas fa-check-circle text-primary me-3" }), - gc.appName.value - ), - h.p( - { class: "text-muted" }, - "A simple and elegant todo list built with OpenScript" - ) - ) - ), - - // Main content area - h.div( - { class: "row" }, - h.div( - { class: "col-md-8 offset-md-2 col-lg-6 offset-lg-3" }, - - // Card container - h.div( - { class: "card shadow-sm" }, - - // Card body - h.div( - { class: "card-body p-4" }, - - // Todo input form placeholder - h.div( - { class: "mb-4" }, - h.div( - { class: "input-group" }, - h.input({ - type: "text", - class: "form-control form-control-lg", - placeholder: "What needs to be done?", - id: "todo-input", - }), - h.button( - { - class: "btn btn-primary", - type: "button", - }, - h.i({ class: "fas fa-plus me-2" }), - "Add" - ) - ) - ), - - // Filter tabs - h.ul( - { class: "nav nav-pills mb-4" }, - h.li( - { class: "nav-item" }, - h.a( - { - class: "nav-link active", - href: "#", - }, - "All" - ) - ), - h.li( - { class: "nav-item" }, - h.a( - { - class: "nav-link", - href: "#", - }, - "Active" - ) - ), - h.li( - { class: "nav-item" }, - h.a( - { - class: "nav-link", - href: "#", - }, - "Completed" - ) - ) - ), - - // Todo list placeholder - h.div( - { class: "todo-list" }, - tc.todos.value.length === 0 - ? h.div( - { class: "text-center text-muted py-5" }, - h.i({ class: "fas fa-inbox fa-3x mb-3 d-block" }), - h.p("No todos yet. Add one above to get started!") - ) - : h.div( - { class: "list-group" }, - ...tc.todos.value.map((todo) => - h.div( - { - class: `list-group-item d-flex align-items-center ${ - todo.completed ? "bg-light" : "" - }`, - }, - h.input({ - type: "checkbox", - class: "form-check-input me-3", - checked: todo.completed, - }), - h.span( - { - class: todo.completed - ? "text-decoration-line-through text-muted flex-grow-1" - : "flex-grow-1", - }, - todo.text - ), - h.button( - { - class: "btn btn-sm btn-outline-danger", - type: "button", - }, - h.i({ class: "fas fa-trash" }) - ) - ) - ) - ) - ) - ), - - // Card footer with stats - h.div( - { class: "card-footer bg-transparent" }, - h.div( - { class: "d-flex justify-content-between align-items-center" }, - h.small( - { class: "text-muted" }, - `${ - tc.todos.value.filter((t) => !t.completed).length - } items left` - ), - h.small( - { class: "text-muted" }, - h.i({ class: "fas fa-info-circle me-1" }), - `Total: ${tc.todos.value.length}` - ) - ) - ) - ) - ) - ), - - // Footer - h.div( - { class: "row mt-5" }, - h.div( - { class: "col-12 text-center" }, - h.p( - { class: "text-muted small" }, - "Built with ", - h.i({ class: "fas fa-heart text-danger" }), - " using OpenScript Framework" - ) - ) - ), - - ...args - ); - } -} diff --git a/examples/basic-app/routes.js b/examples/basic-app/routes.js deleted file mode 100644 index 808b2c6..0000000 --- a/examples/basic-app/routes.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Routes for Todo App - * Defines application routing using OpenScript router - */ - -import { app, dom } from "../../index.js"; -import { gc } from "./contexts.js"; -import TodoApp from "./pages/TodoApp.js"; - -const router = app("router"); -const h = app("h"); - -/** - * Helper to render a component to the root element - * @param {Component} component - Component to render - */ -const app = (component) => { - return component({ - parent: gc.rootElement, - resetParent: true, - }); -}; - -// ============================================ -// ROUTE DEFINITIONS -// ============================================ - -// Set base path (empty for this simple app) -router.basePath(""); - -// Default route - redirect to home -router.default(() => router.to("home")); - -// Home route - shows all todos -router.on( - "/", - () => { - console.log("Route: Home"); - app(h.TodoApp()); - }, - "home" -); - -// Filter routes -router.prefix("filter").group(() => { - router.on( - "/all", - () => { - console.log("Route: Filter - All"); - app(h.TodoApp()); - }, - "filter.all" - ); - - router.on( - "/active", - () => { - console.log("Route: Filter - Active"); - app(h.TodoApp()); - }, - "filter.active" - ); - - router.on( - "/completed", - () => { - console.log("Route: Filter - Completed"); - app(h.TodoApp()); - }, - "filter.completed" - ); -}); - -console.log("✓ Routes registered"); diff --git a/examples/basic-usage.js b/examples/basic-usage.js deleted file mode 100644 index f689bde..0000000 --- a/examples/basic-usage.js +++ /dev/null @@ -1,28 +0,0 @@ -import { app, State, Component, ojs } from "modular-openscriptjs"; - -// Define a State -const counter = State.state(0); -const h = app("h"); - -// Define a Component -class CounterComponent extends Component { - render(counter, ...args) { - return h.div( - h.h1(`Counter: ${counter.value}`), - h.button( - { - onclick: this.method("increment"), - }, - "Increment" - ), - ...args - ); - } - - increment() { - counter.value++; - } -} - -// Mount the Component -ojs(CounterComponent); diff --git a/examples/component-example.js b/examples/component-example.js deleted file mode 100644 index f849fb4..0000000 --- a/examples/component-example.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Component, app } from "../index.js"; - -const h = app("h"); - -class SenderComponent extends Component { - render(...args) { - return h.button( - { - onclick: () => { - app("broker").emit("message", "Hello from Sender!"); - }, - }, - "Send Message", - ...args - ); - } -} - -class ReceiverComponent extends Component { - constructor() { - super(); - this.message = "Waiting..."; - } - - render(...args) { - return h.div(`Received: ${this.message}`, ...args); - } -} diff --git a/examples/context-state-example.js b/examples/context-state-example.js deleted file mode 100644 index 8441ef8..0000000 --- a/examples/context-state-example.js +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Global State with Contexts Example - * Demonstrates best practice: defining states in contexts and passing to components - */ - -import { - Component, - app, - context, - putContext, - state, - dom, -} from "modular-openscriptjs"; - -const h = app("h"); - -// ============================================ -// 1. INITIALIZE CONTEXTS AND STATES -// ============================================ - -// Create contexts -putContext(["global", "page", "user"], "AppContext"); - -const gc = context("global"); -const pc = context("page"); -const uc = context("user"); - -// Initialize states using .states() helper -pc.states({ - pageTitle: "Dashboard", - loading: false, - currentView: "home", -}); - -uc.states({ - username: "Guest", - isAuthenticated: false, - preferences: { theme: "light" }, -}); - -gc.states({ - appName: "MyApp", - version: "1.0.0", -}); - -// You can also add non-reactive properties -gc.apiUrl = "https://api.example.com"; - -// ============================================ -// 2. COMPONENTS RECEIVE STATE VIA RENDER -// ============================================ - -class PageHeader extends Component { - // Receive pageTitle state as parameter - render(pageTitle, appName, ...args) { - return h.header( - { class: "page-header" }, - h.h1(pageTitle.value), // Access state via .value - h.p({ class: "app-name" }, appName.value), - ...args - ); - } -} - -class UserGreeting extends Component { - // Receive user state - render(username, ...args) { - return h.div( - { class: "greeting" }, - h.p(`Welcome, ${username.value}!`), - ...args - ); - } -} - -class ThemeToggle extends Component { - toggleTheme() { - const current = uc.preferences.value.theme; - uc.preferences.value = { - ...uc.preferences.value, - theme: current === "light" ? "dark" : "light", - }; - } - - // Receive preferences state - render(preferences, ...args) { - return h.button( - { - class: "btn btn-secondary", - listeners: { click: this.toggleTheme }, - }, - `Theme: ${preferences.value.theme}`, - ...args - ); - } -} - -class LoadingIndicator extends Component { - // Receive loading state - render(loading, ...args) { - if (!loading.value) return null; - - return h.div({ class: "loading" }, h.span("Loading..."), ...args); - } -} - -// ============================================ -// 3. MAIN DASHBOARD - PASSES STATES DOWN -// ============================================ - -class Dashboard extends Component { - render(...args) { - return h.div( - { class: "dashboard" }, - // Pass global states to header - h.PageHeader(pc.pageTitle, gc.appName), - - // Pass user state to greeting - h.UserGreeting(uc.username), - - // Pass preferences to theme toggle - h.ThemeToggle(uc.preferences), - - // Pass loading state - h.LoadingIndicator(pc.loading), - - h.div({ class: "content" }, h.p("Dashboard content goes here")), - ...args - ); - } -} - -// ============================================ -// 4. STATE MANAGEMENT UTILITIES -// ============================================ - -// Function to update page -function navigateToPage(pageName) { - pc.loading.value = true; - pc.pageTitle.value = pageName; - - // Simulate async navigation - setTimeout(() => { - pc.loading.value = false; - }, 500); -} - -// Function to login -function login(username) { - uc.username.value = username; - uc.isAuthenticated.value = true; -} - -// ============================================ -// 5. USAGE EXAMPLE -// ============================================ - -function initializeApp() { - // Add state listeners for logging - pc.pageTitle.listener((state) => { - console.log(`Page changed to: ${state.value}`); - }); - - uc.preferences.listener((state) => { - console.log(`Theme changed to: ${state.value.theme}`); - // Could apply theme to document here - document.body.className = `theme-${state.value.theme}`; - }); - - // Render dashboard with special attributes - const dashboard = h.Dashboard({ - parent: document.getElementById("app"), - resetParent: true, // Clear existing content - }); - - // Simulate user login after 1 second - setTimeout(() => { - login("John Doe"); - }, 1000); - - // Simulate page navigation after 2 seconds - setTimeout(() => { - navigateToPage("Profile"); - }, 2000); -} - -// Export for use -export { - Dashboard, - PageHeader, - UserGreeting, - ThemeToggle, - LoadingIndicator, - initializeApp, - navigateToPage, - login, -}; diff --git a/examples/event-handling.js b/examples/event-handling.js deleted file mode 100644 index a76fce6..0000000 --- a/examples/event-handling.js +++ /dev/null @@ -1,147 +0,0 @@ -import { Mediator, Component, app, payload, Utils } from "../index.js"; - -const h = app("h"); - -// 1. Declarative Event Listening (Mediator) -// Mediators are perfect for handling business logic and responding to events. -class AuthMediator extends Mediator { - // The '$$' prefix tells the BrokerRegistrar to register these as event listeners. - // Nested objects create namespaced events. - $$user = { - // Listens to 'user:login' - // Listeners receive 'ed' (EventData string) and 'event' (Event Name) - login: (ed, event) => { - const data = Utils.parsePayload(ed); - console.log("User logged in:", data.message); - - // Respond by emitting another event - app("broker").send( - "user:authenticated", - payload({ user: data.message.username }) - ); - }, - - // Listens to 'user:logout' - logout: (ed, event) => { - console.log("User logged out"); - }, - }; - - $$system = { - // Listens to 'system:boot' - boot: (ed, event) => { - console.log("System booted"); - }, - }; -} - -// 2. Advanced Declarative Listening -class AdvancedMediator extends Mediator { - $$user = { - // Listen to multiple events separated by underscore - // This will trigger on 'user:login' OR 'user:logout' - login_logout: (ed, event) => { - console.log(`User event triggered: ${event}`); - }, - }; -} - -// 3. Component Triggering Events & Listening -class LoginButton extends Component { - // Define a method to handle component events - // The '$_' prefix allows this method to be used as an event listener in the markup - $_onClick(e) { - app("broker").send("user:login", payload({ username: "Alice" })); - } - - render(...args) { - return h.button( - { - // Use the defined method as a listener - onclick: this.$_onClick, - }, - "Login", - ...args - ); - } -} - -// 4. Listening to Component Events -class UserDashboard extends Component { - render(...args) { - return h.div( - h.h3("Dashboard"), - // Listen to the 'rendered' event of the LoginButton component - // Syntax: h.on(ComponentClass, eventName, callback) - h.on(LoginButton, "rendered", () => { - console.log("Login Button has been rendered!"); - }), - h.component(new LoginButton()), - ...args - ); - } -} - -// 5. State Management in Components -import { state } from "../index.js"; - -class Counter extends Component { - // Create state inside the component - count = state(0); - - $_increment() { - this.count.value++; - } - - // Components automatically listen to state changes when state is passed to render - render(...args) { - return h.div( - h.p(`Count: ${this.count.value}`), - h.button({ onclick: this.$_increment }, "Increment"), - ...args - ); - } -} - -// 6. Direct State Listeners -class StateExample extends Component { - count = state(0); - - constructor() { - super(); - - // Direct listener using state.listener() method - this.count.listener((currentState) => { - console.log(`State changed to: ${currentState.value}`); - }); - } - - render(...args) { - return h.div( - h.p(`Count: ${this.count.value}`), - h.button( - { - onclick: () => this.count.value++, - }, - "Increment" - ), - ...args - ); - } -} - -// 7. Imperative Event Listening -// You can also listen to events directly using the broker instance. -app("broker").on("user:authenticated", (ed, event) => { - const data = Utils.parsePayload(ed); - console.log("Imperative listener caught authenticated event:", data.message); -}); - -export { - AuthMediator, - AdvancedMediator, - LoginButton, - UserDashboard, - Counter, - StateExample, -}; diff --git a/examples/full-application.js b/examples/full-application.js deleted file mode 100644 index da4d637..0000000 --- a/examples/full-application.js +++ /dev/null @@ -1,339 +0,0 @@ -/** - * Comprehensive Real-World Example - * This example demonstrates a complete OpenScript application setup, - * mirroring patterns used in the Carata codebase. - */ - -import { - Component, - Mediator, - h, - state, - app, - ojs, - context, - putContext, - payload, - Utils, -} from "../index.js"; - -// ============================================ -// 1. EVENT REGISTRATION -// ============================================ -// Define all application events in a structured object -const $e = { - system: { - booted: true, - needs: { - reload: true, - }, - }, - user: { - authenticated: true, - loggedOut: true, - needs: { - login: true, - logout: true, - profile: true, - }, - has: { - loginError: true, - }, - }, - cart: { - itemAdded: true, - needs: { - addition: true, - removal: true, - allItems: true, - }, - has: { - items: true, - }, - }, -}; - -// Register all events with the broker -app("broker").registerEvents($e); - -// ============================================ -// 2. CONTEXT INITIALIZATION -// ============================================ -// Create application contexts -putContext(["global", "user", "page"], "AppContext"); - -const gc = context("global"); // Global context -const uc = context("user"); // User context -const pc = context("page"); // Page context - -// Initialize states in contexts -gc.states({ - auth: false, - appName: "MyApp", -}); - -uc.states({ - cart: {}, - profile: null, - isLoggedIn: false, -}); - -pc.states({ - currentPage: "Home", - loading: false, -}); - -// Add state listeners -uc.cart.listener((cartState) => { - console.log(`Cart updated: ${Object.keys(cartState.value).length} items`); -}); - -// ============================================ -// 3. MEDIATOR - BUSINESS LOGIC -// ============================================ -class CartMediator extends Mediator { - // Listen to multiple events with underscore separation - $$cart = { - needs: { - // Single event listener - addition: (ed, event) => { - this.addToCart(ed, event); - }, - - removal: (ed, event) => { - this.removeFromCart(ed, event); - }, - - allItems: () => { - this.fetchCart(); - }, - }, - }; - - // Multi-event listener - triggers on user:authenticated OR user:loggedOut - $$user = { - authenticated_loggedOut: (ed, event) => { - console.log(`User status changed: ${event}`); - this.broadcast($e.cart.needs.allItems); - }, - }; - - async addToCart(ed, event) { - const data = Utils.parsePayload(ed); - const product = data.message.product; - - // Simulate API call - console.log(`Adding ${product.name} to cart`); - - // Update cart in context - const currentCart = { ...uc.cart.value }; - currentCart[product.id] = product; - uc.cart.value = currentCart; - - // Broadcast success - this.send($e.cart.itemAdded, payload({ product })); - } - - async removeFromCart(ed, event) { - const data = Utils.parsePayload(ed); - const cartMap = uc.cart.value; - delete cartMap[data.message.productId]; - uc.cart.value = { ...cartMap }; - } - - async fetchCart() { - // Simulate fetching cart from API - console.log("Fetching cart items..."); - } -} - -// ============================================ -// 4. COMPONENT - COUNT BUTTON -// ============================================ -class CounterButton extends Component { - count = state(0); - - // Component method with $_ prefix - $_increment() { - this.count.value++; - } - - render(...args) { - return h.div( - { class: "counter-section" }, - h.p(`Count: ${this.count.value}`), - h.button( - { - class: "btn btn-primary", - onclick: this.$_increment, - }, - "Increment" - ), - ...args - ); - } -} - -// ============================================ -// 5. COMPONENT - PRODUCT CARD -// ============================================ -class ProductCard extends Component { - // Using h.func to create inline event handlers - render(product, ...args) { - return h.div( - { class: "card" }, - h.div( - { class: "card-body" }, - h.h5({ class: "card-title" }, product.name), - h.p({ class: "card-text" }, `$${product.price}`), - h.button( - { - class: "btn btn-success", - // h.func creates a callable string for inline handlers - onclick: h.func( - "broker.send", - $e.cart.needs.addition, - payload({ product }) - ), - }, - "Add to Cart" - ) - ), - ...args - ); - } -} - -// ============================================ -// 6. COMPONENT - SHOPPING CART -// ============================================ -class ShoppingCart extends Component { - render(...args) { - return h.div( - { class: "cart-container" }, - h.h3("Shopping Cart"), - // Using v() for reactive rendering based on state - h.div( - h.p("Items in cart:"), - h.ul( - // Reactive rendering - automatically updates when uc.cart changes - ...Object.values(uc.cart.value).map((product) => - h.li( - product.name, - " - ", - h.button( - { - class: "btn btn-sm btn-danger", - onclick: h.func( - "broker.send", - $e.cart.needs.removal, - payload({ productId: product.id }) - ), - }, - "Remove" - ) - ) - ) - ) - ), - ...args - ); - } -} - -// ============================================ -// 7. COMPONENT - DASHBOARD (Parent Component) -// ============================================ -class Dashboard extends Component { - render(...args) { - const sampleProducts = [ - { id: 1, name: "Widget", price: 9.99 }, - { id: 2, name: "Gadget", price: 19.99 }, - { id: 3, name: "Doohickey", price: 14.99 }, - ]; - - return h.div( - { class: "container mt-4" }, - h.div( - { class: "row" }, - h.div( - { class: "col-md-8" }, - h.h2("Products"), - h.div( - { class: "row" }, - ...sampleProducts.map((product) => - h.div({ class: "col-md-4 mb-3" }, h.ProductCard(product)) - ) - ) - ), - h.div( - { class: "col-md-4" }, - // Render counter button - h.CounterButton(), - h.hr(), - // Render shopping cart - h.ShoppingCart(), - // Listen to ProductCard's 'rendered' event - h.on(ProductCard, "rendered", () => { - console.log("Product card rendered"); - }) - ) - ), - ...args - ); - } -} - -// ============================================ -// 8. INITIALIZATION -// ============================================ -function initializeApp() { - // Initialize mediator - // With IoC, we should register it or just instantiate it if it registers itself - const cartMediator = new CartMediator(); - - // Mount the dashboard component to DOM - // ojs() handles mounting to the root element defined in context or default - ojs(Dashboard); - - // Broadcast system booted event - app("broker").broadcast($e.system.booted); -} - -// ============================================ -// 9. ROUTE SETUP -// ============================================ -// Routes use a fluent API with .on(), .prefix(), and .group() -app("router").on( - "/", - () => { - pc.currentPage.value = "Home"; - }, - "home" -); - -app("router") - .prefix("products") - .group(() => { - app("router").on( - "/{productId}/view", - () => { - pc.currentPage.value = "Product Details"; - // Access params via router.params.productId - }, - "product.view" - ); - }); - -// Start listening to route changes -app("router").listen(); - -// Export for use -export { - Dashboard, - ProductCard, - ShoppingCart, - CounterButton, - CartMediator, - initializeApp, -}; diff --git a/examples/state-example.js b/examples/state-example.js deleted file mode 100644 index e0f81b4..0000000 --- a/examples/state-example.js +++ /dev/null @@ -1,357 +0,0 @@ -/** - * State Management Example - * Demonstrates various state patterns in OpenScript - */ - -import { Component, app, state } from "../index.js"; - -const h = app("h"); - -// ============================================ -// 1. Basic Counter Component with State -// ============================================ -class Counter extends Component { - // Create state directly in the component - count = state(0); - - // Regular component methods (NOT event listeners) - increment() { - this.count.value++; - } - - decrement() { - this.count.value--; - } - - reset() { - this.count.value = 0; - } - - // Component automatically re-renders when state changes - render(...args) { - return h.div( - { class: "counter-container" }, - h.h3("Counter Example"), - h.p({ class: "count-display" }, "Count: ", h.strong(this.count.value)), - h.div( - { class: "button-group" }, - // Using listeners attribute - h.button( - { - class: "btn btn-success", - listeners: { click: this.increment }, - }, - "+" - ), - h.button( - { - class: "btn btn-danger", - listeners: { click: this.decrement }, - }, - "-" - ), - // Alternative: using this.method() - h.button( - { - class: "btn btn-secondary", - onclick: this.method("reset"), - }, - "Reset" - ) - ), - ...args - ); - } -} - -// ============================================ -// 2. Todo List Component with Array State -// ============================================ -class TodoList extends Component { - todos = state([]); - inputValue = state(""); - - addTodo() { - if (this.inputValue.value.trim()) { - // Push new todo to the array - this.todos.value = [ - ...this.todos.value, - { - id: Date.now(), - text: this.inputValue.value, - completed: false, - }, - ]; - this.inputValue.value = ""; - } - } - - toggleTodo(id) { - this.todos.value = this.todos.value.map((todo) => - todo.id === id ? { ...todo, completed: !todo.completed } : todo - ); - } - - deleteTodo(id) { - this.todos.value = this.todos.value.filter((todo) => todo.id !== id); - } - - updateInput(e) { - this.inputValue.value = e.target.value; - } - - render(...args) { - return h.div( - { class: "todo-container" }, - h.h3("Todo List Example"), - - // Input form - h.div( - { class: "input-group mb-3" }, - h.input({ - type: "text", - class: "form-control", - placeholder: "Enter a todo...", - value: this.inputValue.value, - listeners: { - input: this.updateInput, - keypress: (e) => { - if (e.key === "Enter") this.addTodo(); - }, - }, - }), - h.button( - { - class: "btn btn-primary", - listeners: { click: this.addTodo }, - }, - "Add" - ) - ), - - // Todo list - h.ul( - { class: "list-group" }, - ...this.todos.value.map((todo) => - h.li( - { - class: - "list-group-item d-flex justify-content-between align-items-center", - style: todo.completed - ? "text-decoration: line-through; opacity: 0.6" - : "", - }, - h.span( - { - onclick: () => this.toggleTodo(todo.id), - style: "cursor: pointer; flex: 1", - }, - todo.text - ), - h.button( - { - class: "btn btn-sm btn-danger", - onclick: () => this.deleteTodo(todo.id), - }, - "Delete" - ) - ) - ) - ), - - // Stats - h.p( - { class: "mt-3" }, - `Total: ${this.todos.value.length} | `, - `Completed: ${this.todos.value.filter((t) => t.completed).length}` - ), - ...args - ); - } -} - -// ============================================ -// 3. Form Component with Object State -// ============================================ -class UserForm extends Component { - formData = state({ - name: "", - email: "", - age: "", - }); - - submitted = state(false); - - updateField(field, value) { - this.formData.value = { - ...this.formData.value, - [field]: value, - }; - } - - handleSubmit(e) { - e.preventDefault(); - console.log("Form submitted:", this.formData.value); - this.submitted.value = true; - - // Reset after 2 seconds - setTimeout(() => { - this.submitted.value = false; - }, 2000); - } - - render(...args) { - return h.div( - { class: "form-container" }, - h.h3("User Form Example"), - - h.form( - { listeners: { submit: this.handleSubmit } }, - h.div( - { class: "mb-3" }, - h.label({ class: "form-label" }, "Name"), - h.input({ - type: "text", - class: "form-control", - value: this.formData.value.name, - listeners: { - input: (e) => this.updateField("name", e.target.value), - }, - }) - ), - h.div( - { class: "mb-3" }, - h.label({ class: "form-label" }, "Email"), - h.input({ - type: "email", - class: "form-control", - value: this.formData.value.email, - listeners: { - input: (e) => this.updateField("email", e.target.value), - }, - }) - ), - h.div( - { class: "mb-3" }, - h.label({ class: "form-label" }, "Age"), - h.input({ - type: "number", - class: "form-control", - value: this.formData.value.age, - listeners: { - input: (e) => this.updateField("age", e.target.value), - }, - }) - ), - h.button({ type: "submit", class: "btn btn-primary" }, "Submit"), - this.submitted.value - ? h.div( - { class: "alert alert-success mt-3" }, - "Form submitted successfully!" - ) - : null - ), - ...args - ); - } -} - -// ============================================ -// 4. State with Listeners -// ============================================ -class StateListenerExample extends Component { - temperature = state(20); - - constructor() { - super(); - - // Add a listener that fires whenever temperature changes - this.temperature.listener((tempState) => { - console.log(`Temperature changed to: ${tempState.value}°C`); - - // You could trigger side effects here - if (tempState.value > 30) { - console.warn("Temperature is getting high!"); - } - }); - } - - increase() { - this.temperature.value += 5; - } - - decrease() { - this.temperature.value -= 5; - } - - render(...args) { - const temp = this.temperature.value; - let status = "Normal"; - let statusClass = "badge bg-success"; - - if (temp > 30) { - status = "Hot"; - statusClass = "badge bg-danger"; - } else if (temp < 10) { - status = "Cold"; - statusClass = "badge bg-primary"; - } - - return h.div( - { class: "temperature-container" }, - h.h3("State Listener Example"), - h.p("Check console for state change logs"), - h.div( - { class: "display-4" }, - `${temp}°C `, - h.span({ class: statusClass }, status) - ), - h.div( - { class: "button-group mt-3" }, - h.button( - { - class: "btn btn-primary", - listeners: { click: this.increase }, - }, - "Increase" - ), - h.button( - { - class: "btn btn-info", - listeners: { click: this.decrease }, - }, - "Decrease" - ) - ), - ...args - ); - } -} - -// ============================================ -// 5. Demo Page - All Examples Together -// ============================================ -class StateDemo extends Component { - render(...args) { - return h.div( - { class: "container mt-4" }, - h.h1("OpenScript State Management Examples"), - h.hr(), - - h.div( - { class: "row" }, - h.div({ class: "col-md-6 mb-4" }, h.Counter()), - h.div({ class: "col-md-6 mb-4" }, h.StateListenerExample()) - ), - - h.div( - { class: "row" }, - h.div({ class: "col-md-6 mb-4" }, h.TodoList()), - h.div({ class: "col-md-6 mb-4" }, h.UserForm()) - ), - ...args - ); - } -} - -export { Counter, TodoList, UserForm, StateListenerExample, StateDemo }; diff --git a/examples/vite.config.example.js b/examples/vite.config.example.js deleted file mode 100644 index 8d60605..0000000 --- a/examples/vite.config.example.js +++ /dev/null @@ -1,24 +0,0 @@ -// Example vite.config.js for OpenScript projects -import { defineConfig } from "vite"; -import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; - -export default defineConfig({ - plugins: [ - openScriptComponentPlugin({ - // Directory where your components are located - componentsDir: "src/components", - - // Auto-register all components on app start - // Set to false if you want manual control - autoRegister: true, - - // Generate TypeScript definitions for IDE autocomplete - // Creates src/openscript-components.d.ts - generateTypes: true, - }), - ], - - build: { - target: "es2015", - }, -}); diff --git a/package.json b/package.json index 7e3d215..05bcccf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "2.0.4", + "version": "2.0.8", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/component/Component.js b/src/component/Component.js index c42a7c7..c35228c 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -84,7 +84,6 @@ export default class Component { this.isAnonymous = false; - this.name = name ?? this.constructor.name; this.emitter.once(this.EVENTS.rendered, (componentId) => { let repo = container.resolve("repository"); diff --git a/src/core/Runner.js b/src/core/Runner.js index b9a238f..891e6f7 100644 --- a/src/core/Runner.js +++ b/src/core/Runner.js @@ -68,7 +68,7 @@ export default class Runner { if (instance instanceof Component) { registrations[classKey] = "completed"; - h.registerComponent(instanceName, c); + h.registerComponent(instance.name, c); } else if (instance instanceof Mediator || instance instanceof Listener) { await instance.register(); registrations[classKey] = "completed"; diff --git a/test_output.txt b/test_output.txt deleted file mode 100644 index 2d788dd71d2ed1c9c8f4470689c0307e7070feb9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16318 zcmeI3>uy^`5XV>QHzb~*Uk+6Tsem}Kle7r}QF=i|y^$7#)D}pSGz6N9V)rH}FVROp zJOy8QBt8TG-;Sqqj(wciPCy79Mb0_i-PyV9>|A!%fBpGpdLw<5inN#Z(`MRD-L#$# z(zd?!()arIahgxFX)b>XwQZ`cmsT|9iAFp0Q$cuNQR<_g&*^ke^X%m3LR!*Wkyg_e z>876aEu`gsZ#%VfFRh(C@~L`_f_b&r_|EI`%c5;3T@ppDbVt;!i<8~7k=8`T3$@+q z^L0n-qF)cytY0Q)KIFEb3mV2WBx||Eg4TY~x{gVF3v>PoU8|dh1UgR}9A36$MK-lQtkh&&EltVOd|Di9jdn%TMwazv zdah?7D_LY~c2V>e*=pWTxAR`#(*HGSob_K7XK>X`_r;aXf;ZsXzDCXJe^)kVMfL|x z!$Pj7@A6u&$V&75UA6P~wmxTN>2IaW(tvOD`AQ#ayPa;RRMLU?sq0j04UV4z;({oi2_#tSxqV>Tu;dQswW}ek4K$2Ipw|Xo(KiAuuRwuAt z5iacYfq2-^*uZBielv5rot_GV*_9o&;3WgnDCEC+47~NLQ@^L*j`$z`<#gt!68<0D=C8slcBEtD(E+s48x+yz94+LT=yR$rN?OeH+@2U>+e zD+L8W2M@vdc(Va*vhp~mjxSqHUxU!SkO2FKDDFVIx}A4~$@7eC%Ng}yi?Cb=B_0`V zy2XC8@fHOVh0(?ca9E_~Qnu(aBC=PfewDTdEjAPTg_Vz)W;4F7+8^f~RY=mrl`VbP zgT$9WfoBjB8@kfRLA*C7@3f+4r-aui1=?dzw*&N8ybEMI{UmOKFCP{BElQ_M66ivC zWH-yCDr4*K2l?9ika*UjKIy7da4*4W^fen2F?6iqN+^ijO2$;+DsPr zb~2U^N?3q(zL_he(-})T*wEm{R@@?U!}V6Fx5^Qg{KrgtJ^qCe_!(VYA*71+epbRY zI0hZA_8Zm6v$~H$$DI#$^|wR2>P!in%|%vrRy?^Kx0!qeV$91SNHDNF;cNOG`fX|6 z$=2Iepnf;_>bTr_8ZY>PuS+;b>(b(gwS0NV`Y#Q@_?nxB9%glht;xlV=ZW^lo;=o` z_5@KKc}>-xT#zkk%bLJncr=T~+?LQcJ`Gq;a6K0;I2gt^jLk7Q$6@u+lW}4S#sGa( z>@j*SppTAS%J9SmElKTxD0JvSIo5Mlo@La#X&msryU;#bEiNf^7P6b;XXNd%Qta$* zUuze#Tx_1|8jlwznN))szS}pahbOIH%F=rjP{@m{DSFrvg?L|1VL1IE-Y?S&9C~T7 zC^U1%5^*q=jw~eRwNoV1t-I>QyA+~e`!z1ZjO{ICtBHH<9b9C`-At#&wlQ;8`f3q& z*-rygM|YKGb{RCR=qTu^J6_#0;?eP;gD6LY(T%+{Z+Jh3H?VHoXGxU}|<9Kvr_b}^= zoi#Nv9ukWvrit;)8o3v*N~ekOni$VD!=G5B|9*^DK3~T|ldnU$=j_CGcofg$@lZGP z9S_QBT;!Z?J%MIaE7i?ip0^sqEv55{8iAhITi9#fBA1ALcUa_FhxsV3IGZBF>&lz2 z$yeewS7!Z z?q+HC`X_-_VE}_y87vFGtncfJwk)#QP~KrDN{oU7;xu0;pgCu#)+_83q}n!^u~5GP zdRc39;h20ePx*A79YnMjbjJBibsRea=s1~K>}$<&Y4S*yQ#oisc3>K(b`aFGrNv}+ zbCM9y9#c)TNBuPa8?BbtK$(*r*iyB(T*+pQ14u4#*5Sy9ijv>0I_SW>YrqQLe{4)DDG?XE_fg2WK8@2|Q*~b~A>peohyD zvMTpcSyb#>-aK<^GPyWo4+(`t?be9t6B%lAT5o66&1pT6DktI1X?=5AKc~o}z7p`y zoYvRn1lX*@@`5IlYce_F`Qv7CRGA0!D|B!249>GRpyS5bxEY*hZ$NvrH|)WWt~vo9 zd6J!h{68|d#hec{8Jyj@Wt~ql-)u5C{64u=lfg9^T-4i}T3q$4ySZ(|DR(u}1&J`&R$Y3=Y}Xaduc<8kG{D zk6*8{8i4lgViYF^J*Q+ zsf^t)KFueW$xxw<-{njzM?ZT%UM=c^C~0a@|5W9y$z+;LhTjIAL@ml}Enat29colN zR&P|_(d|H=?9E-{qqL9Z#t44N$NiOVt|)Gl{9JW5kDq3A+rjU+J6|>ue&WwLqskdG*?W}&PM9X8 z0hptyxbnJYd^i=*(-93(q4i&VRPQTS*A8CqXzjRX!U}SZQRQ`BvRVV^=o-@M2Z=HX5JS){Kni`IVpXt zKfBjkNUNBAx!>zP7D Date: Wed, 21 Jan 2026 09:22:05 +0300 Subject: [PATCH 35/46] working on openscript documentation --- docs/components.md | 124 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/components.md diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 0000000..0dba705 --- /dev/null +++ b/docs/components.md @@ -0,0 +1,124 @@ +# Components + +Components are the building blocks of an OpenScript application. They encapsulate the UI and logic for a specific part of your application. + +There are two primary ways to create components: **Class Components** and **Functional Components**. + +## Class Components + +Class components are the most feature-rich way to define components. They extend the `Component` class provided by `modular-openscriptjs`. + +### Basic Structure + +```javascript +import { Component, app, ojs, state } from "modular-openscriptjs"; + +const h = app("h"); + +export default class MyComponent extends Component { + constructor() { + super(); + // Initialize state + this.counter = state(0); + } + + render(...args) { + return h.div( + h.h1("My Component"), + h.p(`Count: ${this.counter.value}`), + ...args, + ); + } +} + +// Register the component +ojs(MyComponent); +``` + +### Key Features + +1. **Extends `Component`**: Must extend the base class. +2. **`render()` Method**: Must implement a `render` method that returns the markup (usually via `h` instance). +3. **State Management**: Can hold local state using `state()`. +4. **`ojs(MyComponent)`**: Registers the component with the framework. +5. **Passing Arguments**: The `render` method receives `...args` which can contain parent elements, attributes, or other data passed during rendering. Always spread `...args` in your root element or handle them appropriately. + +## Functional Components + +Functional components are simpler and are best used for presentational components that don't require complex state management or lifecycle hooks. + +### Basic Structure + +```javascript +import { app } from "modular-openscriptjs"; + +const h = app("h"); + +export default function MyFunctionalComponent(props = {}) { + return h.div( + { class: "card" }, + h.h2(props.title || "Default Title"), + h.p(props.content), + ); +} +``` + +_Note: Functional components are just regular JavaScript functions that return valid OpenScript markup._ + +## Naming Conventions + +- **Capitalized Names**: Component names (both class and function) **MUST** start with a Capital letter (e.g., `MyComponent`, `UserProfile`). +- **Kebab-case in DOM**: When rendered, OpenScript converts the class name to kebab-case for the custom element tag (e.g., `MyComponent` -> ``). + +## Event Listening + +OpenScript provides a declarative way to listen to events on elements. + +### Using `listeners` Object + +When creating an element with `h`, you can pass a `listeners` object in the attributes. + +```javascript +h.button( + { + class: "btn", + listeners: { + click: this.handleClick.bind(this), + mouseover: (e) => console.log("Hovered", e), + }, + }, + "Click Me", +); +``` + +### Method Binding + +For class components, it's common to define methods for event handlers. Remember to `.bind(this)` or use arrow functions to preserve the correct `this` context. + +```javascript +export default class Counter extends Component { + // ... + + increment() { + this.count.value++; + } + + render() { + return h.button( + { + listeners: { + click: this.increment.bind(this), // Binding is crucial + }, + }, + "+", + ); + } +} +``` + +### Special Event Methods + +If your component class defines methods starting with `$_`, OpenScript automatically treats them as event listeners for the component instance itself (lifecycle events). + +- `$_mounted()`: Called when the component is added to the DOM. +- `$_rendered()`: Called when the component is rendered. From b90836ebabaf3df6bb986f2803a7db81aefcb4e6 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Thu, 22 Jan 2026 06:04:00 +0300 Subject: [PATCH 36/46] fixed methods and events reconciliation --- package.json | 2 +- src/component/Component.js | 3 +- src/component/DOMReconciler.js | 24 ++++++++++----- src/component/MarkupEngine.js | 54 ++++++++++++++++------------------ src/utils/helpers.js | 14 +++++++-- 5 files changed, 56 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 05bcccf..ff771ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "2.0.8", + "version": "2.0.9", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/component/Component.js b/src/component/Component.js index c35228c..90d3056 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -219,6 +219,7 @@ export default class Component { this.releaseMemory(); this.unmounted = true; + this.emit(this.EVENTS.unmounted); return true; } @@ -510,7 +511,7 @@ export default class Component { this.emit(this.EVENTS.rendered, this.id); - if (parent && parent.isConnected) { + if (parent && parent.isConnected && this.mounted == false) { this.emit(this.EVENTS.mounted, this.id); } diff --git a/src/component/DOMReconciler.js b/src/component/DOMReconciler.js index 68b81f1..04fa894 100644 --- a/src/component/DOMReconciler.js +++ b/src/component/DOMReconciler.js @@ -1,5 +1,10 @@ import { container } from "../core/Container"; -import { destroyNodeDeep, removeDomMethod } from "../utils/helpers"; +import { + destroyNodeDeep, + indirectEventHandler, + registerDomListeners, + removeDomMethod, +} from "../utils/helpers"; /** * DOMReconciler Class @@ -92,20 +97,20 @@ export default class DOMReconciler { } replaceEventListeners(targetNode, sourceNode) { - const events = this.getEventListeners(targetNode); - - for (const [eventName, listeners] of events) { - listeners.forEach((listener) => { - targetNode.removeListener(eventName, listener); - }); + if (targetNode.removeAllListeners) { + targetNode.removeAllListeners(); } const sourceEvents = this.getEventListeners(sourceNode); for (const [eventName, listeners] of sourceEvents) { listeners.forEach((listener) => { - targetNode.addListener(eventName, listener); + registerDomListeners(targetNode, eventName, listener); }); + + if (targetNode.addListener) { + targetNode.addListener(eventName, indirectEventHandler); + } } } @@ -122,6 +127,9 @@ export default class DOMReconciler { container.resolve("repository").domMethods.set(targetNode, targetMethods); } + // remove all previous methods + container.resolve("repository").domMethods.get(targetNode)?.clear(); + for (const [name, fn] of methodsMap) { removeDomMethod(targetNode, name); targetMethods.set(name, fn); diff --git a/src/component/MarkupEngine.js b/src/component/MarkupEngine.js index 4ff89c8..5d2975a 100644 --- a/src/component/MarkupEngine.js +++ b/src/component/MarkupEngine.js @@ -3,7 +3,12 @@ import Utils from "../utils/Utils.js"; import Component from "./Component.js"; import State from "../core/State.js"; import { container } from "../core/Container.js"; -import { defineDomMethod, indirectEventHandler, isClass } from "../utils/helpers.js"; +import { + defineDomMethod, + indirectEventHandler, + isClass, + registerDomListeners, +} from "../utils/helpers.js"; /** * Base Markup Engine Class @@ -37,13 +42,13 @@ export default class MarkupEngine { registerComponent = (name, componentClass) => { if (!(typeof name === "string")) { throw Error( - `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` + `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given`, ); } if (!(componentClass.prototype instanceof Component)) { throw new Error( - `MarkupEngine.Exception: The component for ${name} must be an Component component. ${componentClass.name} given` + `MarkupEngine.Exception: The component for ${name} must be an Component component. ${componentClass.name} given`, ); } @@ -83,7 +88,7 @@ export default class MarkupEngine { if (this.hasComponent(name)) return true; console.warn( - `MarkupEngine.Warn: Trying to ${method} an unregistered component {${name}}. Please ensure that the component is registered by using h.has(componentName)` + `MarkupEngine.Warn: Trying to ${method} an unregistered component {${name}}. Please ensure that the component is registered by using h.has(componentName)`, ); return false; @@ -105,7 +110,8 @@ export default class MarkupEngine { // remove the listener from the event map in the repository // since for each event, there is only the indirect event handler // listening to that event. - let eventMap = container.resolve("repository").domListeners.get(this) ?? new Map(); + let eventMap = + container.resolve("repository").domListeners.get(this) ?? new Map(); let listeners = eventMap.get(event) ?? new Set(); @@ -124,7 +130,9 @@ export default class MarkupEngine { }; element.getEventListeners = function () { - return container.resolve("repository").domListeners.get(this) ?? new Map(); + return ( + container.resolve("repository").domListeners.get(this) ?? new Map() + ); }; element.toString = function () { @@ -135,7 +143,8 @@ export default class MarkupEngine { let methods = {}; // get the methods from the repository - let methodsMap = container.resolve("repository").domMethods.get(this) ?? new Map(); + let methodsMap = + container.resolve("repository").domMethods.get(this) ?? new Map(); for (let [k, v] of methodsMap) { methods[k] = v; @@ -149,6 +158,7 @@ export default class MarkupEngine { this.removeEventListener(event, indirectEventHandler); }); this.__eventListeners?.clear(); + container.resolve("repository").domListeners.get(this)?.clear(); }; element.__openscript_cleanup__ = () => { @@ -180,7 +190,7 @@ export default class MarkupEngine { handle = (name, ...args) => { if (!(typeof name === "string")) { throw Error( - `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given` + `MarkupEngine.Exception: A Component's name must be a string: type '${typeof name}' given`, ); } @@ -213,7 +223,6 @@ export default class MarkupEngine { return cmp.wrap(...args); } - /** * @type {DocumentFragment|HTMLElement} */ @@ -285,7 +294,7 @@ export default class MarkupEngine { if (k === "listeners") { if (typeof v !== "object") { throw TypeError( - `The value of 'listeners' should be an object. but found ${typeof v}` + `The value of 'listeners' should be an object. but found ${typeof v}`, ); } @@ -294,12 +303,12 @@ export default class MarkupEngine { if (Array.isArray(listener)) { listener.forEach((l) => { - this.registerDomListeners(root, evt, l); + registerDomListeners(root, evt, l); }); root.addListener(evt, indirectEventHandler); } else { - this.registerDomListeners(root, evt, listener); + registerDomListeners(root, evt, listener); root.addListener(evt, indirectEventHandler); } } @@ -310,10 +319,10 @@ export default class MarkupEngine { if (k === "methods") { if (typeof v !== "object") { throw TypeError( - `The value of 'methods' attribute should be an object. but found ${typeof v}` + `The value of 'methods' attribute should be an object. but found ${typeof v}`, ); } - + let methodMap = container.resolve("repository").domMethods.get(root); if (!methodMap) { methodMap = new Map(); @@ -346,7 +355,7 @@ export default class MarkupEngine { `MarkupEngine.ParseAttribute.Exception: `, e, `. Attributes resulting in the error: `, - obj + obj, ); throw Error(e); } @@ -444,7 +453,7 @@ export default class MarkupEngine { f = () => { const h = container.resolve("h"); return h["ojs-group"](); - } + }, ) => { return f(); }; @@ -522,17 +531,4 @@ export default class MarkupEngine { toElement = (value) => { return value; }; - - registerDomListeners = (node, event, listener) => { - let eventMap = container.resolve("repository").domListeners.get(node); - - if (!eventMap) { - eventMap = new Map(); - container.resolve("repository").domListeners.set(node, eventMap); - } - - let listeners = eventMap.get(event) ?? new Set(); - listeners.add(listener); - eventMap.set(event, listeners); - }; } diff --git a/src/utils/helpers.js b/src/utils/helpers.js index d25db13..f0b42fd 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -78,8 +78,7 @@ export function cleanupDisconnectedComponents() { const repo = container.resolve("repository"); for (const [id, component] of repo.components) { - - if(!component?.mounted === true) continue; + if (!component?.mounted === true) continue; let markups = component.markup(); @@ -94,4 +93,15 @@ export function getOjsChildren(parent) { return parent?.querySelectorAll(".__ojs-c-class__") ?? []; } +export function registerDomListeners(node, event, listener) { + let eventMap = container.resolve("repository").domListeners.get(node); + + if (!eventMap) { + eventMap = new Map(); + container.resolve("repository").domListeners.set(node, eventMap); + } + let listeners = eventMap.get(event) ?? new Set(); + listeners.add(listener); + eventMap.set(event, listeners); +} From 4ae8644d6d858d015b4f634e3a9a9ec26a683c39 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Fri, 23 Jan 2026 09:45:12 +0300 Subject: [PATCH 37/46] added callback to handle cleanups --- build/vite-plugin-openscript.js | 9 +++- docs/components.md | 67 +++++++++++++++++++++++++-- docs/osm.md | 76 +++++++++++++++++++++++++++++++ docs/setting-up.md | 14 ++++++ package.json | 2 +- src/component/Component.js | 1 - src/core/Container.js | 37 ++++++++++++++- src/core/Repository.js | 10 ++++ src/index.js | 6 ++- src/utils/helpers.js | 27 +++++++++++ templates/basic/src/ojs.config.js | 6 +++ 11 files changed, 246 insertions(+), 9 deletions(-) create mode 100644 docs/osm.md diff --git a/build/vite-plugin-openscript.js b/build/vite-plugin-openscript.js index a5078fa..8499bb0 100644 --- a/build/vite-plugin-openscript.js +++ b/build/vite-plugin-openscript.js @@ -203,8 +203,15 @@ export function openScriptComponentPlugin(options = {}) { if (hasChanged) { // Check if Component is imported // Matches: import { Component } ... or import ... Component ... + // We use a regex that supports multi-line named imports by looking inside braces + + const hasNamedImport = + /import\s*\{[^}]*?\bComponent\b[^}]*?\}\s*from/.test(code); + const hasDefaultOrNamespaceImport = + /import\s+(?:[\w*\s,]*\bComponent\b)/.test(code); + // Simple check: - if (!code.includes("import") || !code.match(/import\s+.*Component/)) { + if (!hasNamedImport && !hasDefaultOrNamespaceImport) { // Need to import Component. // Check if existing import from "modular-openscriptjs" exists if (code.includes("modular-openscriptjs")) { diff --git a/docs/components.md b/docs/components.md index 0dba705..dbc8481 100644 --- a/docs/components.md +++ b/docs/components.md @@ -78,12 +78,18 @@ OpenScript provides a declarative way to listen to events on elements. When creating an element with `h`, you can pass a `listeners` object in the attributes. +> [!TIP] +> It is recommended to use **anonymous functions** for listeners to avoid potential memory leaks associated with direct binding. While OpenScript is generally memory-safe, using anonymous functions ensures that references are properly managed and collected. + ```javascript h.button( { class: "btn", listeners: { - click: this.handleClick.bind(this), + // Preferred: Anonymous function + click: (e) => this.handleClick(e), + + // Also safe: Arrow functions defined inline mouseover: (e) => console.log("Hovered", e), }, }, @@ -93,7 +99,7 @@ h.button( ### Method Binding -For class components, it's common to define methods for event handlers. Remember to `.bind(this)` or use arrow functions to preserve the correct `this` context. +While you can bind methods directly, be aware that creating new bound functions (e.g., `.bind(this)`) on every render can potentially lead to memory overhead if not handled correctly by the garbage collector. ```javascript export default class Counter extends Component { @@ -107,7 +113,8 @@ export default class Counter extends Component { return h.button( { listeners: { - click: this.increment.bind(this), // Binding is crucial + // Anonymous function wrapper is preferred over .bind(this) + click: () => this.increment(), }, }, "+", @@ -118,7 +125,59 @@ export default class Counter extends Component { ### Special Event Methods -If your component class defines methods starting with `$_`, OpenScript automatically treats them as event listeners for the component instance itself (lifecycle events). +OpenScript provides conventions for automatically listening to events based on method names. + +#### Component Lifecycle & Emitted Events (`$_`) + +Methods starting with `$_` are treated as listeners for events emitted by the component itself (including lifecycle events). - `$_mounted()`: Called when the component is added to the DOM. - `$_rendered()`: Called when the component is rendered. +- `$_customEvent()`: Listens for `this.emit('customEvent')`. + +#### Broker Events (`$$`) + +Methods starting with `$$` are treated as listeners for global events emitted via the **Broker**. + +- `$$app_started()`: Listens for `app:started` event (dots/colons usually mapped to underscores). +- `$$user_login()`: Listens for `user:login` event. + +```javascript +export default class UserProfile extends Component { + // Listen to component's own mount event + $_mounted() { + console.log("UserProfile mounted"); + } + + // Listen to global 'auth:logout' event from Broker + $$auth_logout(user) { + console.log("User logged out:", user); + this.cleanUp(); + } +} +``` + +### Inline Attribute Listeners + +For inline event attributes (like `onclick`, `onchange`, etc.) that mimic standard HTML attributes, you can use `this.method('methodName', ...args)`. This approach allows you to reference component methods directly in the string attribute, which is useful when standard `listeners` object binding isn't applicable or preferred for specific attribute-based APIs. + +```javascript +export default class MyComponent extends Component { + greet(name) { + alert(`Hello, ${name}!`); + } + + render() { + // Uses this.method to create a reference to the 'greet' method + // 'onclick' here is treated as an attribute, not a direct event listener attachment + return h.button( + { + onclick: this.method("greet", "Levi"), // effectively onclick="...greet('Levi')" + }, + "Say Hello", + ); + } +} +``` + +_Note: `this.method()` is specifically for attributes that expect a string script (like `onclick` in HTML), bridging them back to your component's methods._ diff --git a/docs/osm.md b/docs/osm.md new file mode 100644 index 0000000..2fbb350 --- /dev/null +++ b/docs/osm.md @@ -0,0 +1,76 @@ +# OpenScript Markup (OSM) + +OpenScript Markup (OSM) is a powerful, JavaScript-based Domain Specific Language (DSL) for generating HTML. It uses the `h` object, a proxy that translates methods into HTML elements. + +## Basic Syntax + +To use OSM, you need to import the `app` instance and retrieve the `h` service. + +```javascript +import { app } from "modular-openscriptjs"; + +const h = app("h"); + +// Simple element +const div = h.div("Hello World"); +// Output:
Hello World
+``` + +### How it Works + +The `h` object is a **Proxy**. When you access a property on it (e.g., `h.div`, `h.span`, `h.customElement`), it returns a function that generates an element with that tag name. + +> **Note**: OSM supports all standard HTML tags (`h.section`, `h.a`, `h.img`) and custom elements (`h.myComponent`). + +## Attributes & Properties + +You can pass attributes as the first argument to the tag function if it is an object (and not a DOM node or State). + +```javascript +h.a( + { + href: "https://example.com", + class: "link primary", + target: "_blank", + id: "main-link", + }, + "Visit Example", +); +``` + +### Boolean Attributes + +Boolean attributes work as expected: + +```javascript +h.input({ type: "checkbox", checked: true, disabled: false }); +``` + +### Style Object + +You can pass a style string directly: + +```javascript +h.div({ style: "color: red; font-weight: bold;" }, "Styled Text"); +``` + +## Children & Text Content + +Arguments after the attributes (or the first argument if it's not an attributes object) are treated as children. + +```javascript +h.ul( + { class: "list" }, + h.li("Item 1"), + h.li("Item 2"), + h.li(h.strong("Item 3 with bold text")), +); +``` + +### Text Nodes + +Strings and numbers are automatically converted to text nodes. + +```javascript +h.p("You have ", 5, " notifications."); +``` diff --git a/docs/setting-up.md b/docs/setting-up.md index da38b52..d227f90 100644 --- a/docs/setting-up.md +++ b/docs/setting-up.md @@ -170,12 +170,26 @@ export function configureApp() { * --------------------------------------------- */ app().value("appEvents", appEvents); + + /** + * --------------------------------------------- + * Node Disposal Callback + * --------------------------------------------- + * Use this to clean up external library instances + * attached to DOM nodes when they are removed. + */ + registerNodeDisposalCallback((node) => { + // Example: Dispose Bootstrap tooltips/popovers + // if (node._bootstrap_tooltip) node._bootstrap_tooltip.dispose(); + }); } // execute configuration configureApp(); ``` +> **Note**: `registerNodeDisposalCallback` is crucial for preventing memory leaks when using third-party libraries that attach instances to DOM elements (like Bootstrap, Tippy.js, etc.). The callback **MUST** be synchronous and stateless. + > **Note**: In the configuration above, we are using `appEvents` imported from `events.js`. We will cover the creation of `events.js` and how to handle events in the subsequent sections. ## 5. Define Application Events diff --git a/package.json b/package.json index ff771ed..4328c74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "2.0.9", + "version": "2.0.12", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/component/Component.js b/src/component/Component.js index 90d3056..579f917 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -84,7 +84,6 @@ export default class Component { this.isAnonymous = false; - this.emitter.once(this.EVENTS.rendered, (componentId) => { let repo = container.resolve("repository"); let component = repo.findComponent(componentId); diff --git a/src/core/Container.js b/src/core/Container.js index db44863..83bb819 100644 --- a/src/core/Container.js +++ b/src/core/Container.js @@ -86,7 +86,42 @@ export default class Container { }); return this; } - + /** + * Access services from the IoC container + * @overload + * @param {'h'} instance - Get the MarkupEngine instance + * @returns {MarkupEngine} + */ + /** + * @overload + * @param {'repository'} instance - Get the Repository instance + * @returns {Repository} + */ + /** + * @overload + * @param {'router'} instance - Get the Router instance + * @returns {Router} + */ + /** + * @overload + * @param {'broker'} instance - Get the Broker instance + * @returns {Broker} + */ + /** + * @overload + * @param {'contextProvider'} instance - Get the ContextProvider instance + * @returns {ContextProvider} + */ + /** + * @overload + * @param {'mediatorManager'} instance - Get the MediatorManager instance + * @returns {MediatorManager} + */ + /** + * @overload + * @param {'repository'} instance - Get the Repository instance + * @returns {Repository} + */ /** * Resolve a service by name * @param {string} name - Service identifier diff --git a/src/core/Repository.js b/src/core/Repository.js index 9077e4e..dbe98be 100644 --- a/src/core/Repository.js +++ b/src/core/Repository.js @@ -36,6 +36,8 @@ export default class Repository { this.domListeners = new WeakMap(); this.domMethods = new WeakMap(); + + this.nodeDisposalCallbacks = new Set(); } /** @@ -156,4 +158,12 @@ export default class Repository { if (!component) return; this.componentArgsMap.delete(component); } + + /** + * Get the node disposal callbacks + * @returns {Set} + */ + getNodeDisposalCallbacks() { + return this.nodeDisposalCallbacks; + } } diff --git a/src/index.js b/src/index.js index 94c2616..74fd54c 100644 --- a/src/index.js +++ b/src/index.js @@ -23,7 +23,7 @@ import MarkupHandler from "./component/MarkupHandler.js"; import Utils from "./utils/Utils.js"; import DOM from "./utils/DOM.js"; -import { cleanUpNode, isClass } from "./utils/helpers.js"; +import { cleanUpNode, isClass, registerNodeDisposalCallback, removeNode } from "./utils/helpers.js"; // Initialize global instances const broker = new Broker(); @@ -191,6 +191,8 @@ export { payload, ojsRouterEvents, removeNodeModifications, + removeNode, + registerNodeDisposalCallback }; // Default export object @@ -233,6 +235,8 @@ export default { payload, ojsRouterEvents, removeNodeModifications, + removeNode, + registerNodeDisposalCallback }; // Add necessary globals diff --git a/src/utils/helpers.js b/src/utils/helpers.js index f0b42fd..b0909d9 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -71,6 +71,12 @@ export function destroyNodeDeep(node) { destroyNodeDeep(child); } + if(container.resolve("repository")?.getNodeDisposalCallbacks()?.size) { + for (const callback of container.resolve("repository").getNodeDisposalCallbacks()) { + callback(node); + } + } + cleanUpNode(node); } @@ -105,3 +111,24 @@ export function registerDomListeners(node, event, listener) { listeners.add(listener); eventMap.set(event, listeners); } + +/** + * used to safely remove a node from the DOM + * @param {Node} node + */ +export function removeNode(node) { + destroyNodeDeep(node); + node.remove(); +} + +/** + * used to register a callback that will be called when a node is removed from the DOM. Use this to clean up the node to avoid memory leaks. e.g. Remove Bootstrap components attached to the node. + * **The Callback Must Be Stateless!** + * **It must not be asynchronous!** + * **If you don't understand, GOOGLE IT!** + * @param {(node: Node) => void} syncStatelessCallback + * @returns {void} + */ +export function registerNodeDisposalCallback(syncStatelessCallback) { + container.resolve("repository").nodeDisposalCallbacks?.add(syncStatelessCallback); +} diff --git a/templates/basic/src/ojs.config.js b/templates/basic/src/ojs.config.js index 6d0b13c..8ff0fdc 100644 --- a/templates/basic/src/ojs.config.js +++ b/templates/basic/src/ojs.config.js @@ -1,5 +1,6 @@ import { app } from "modular-openscriptjs"; import { appEvents } from "./events.js"; +import { registerNodeDisposalCallback } from "../../../src/index.js"; /*---------------------------------- | Do OpenScript Configurations Here @@ -82,4 +83,9 @@ export function configureApp() { * --------------------------------------------- */ container.value("appEvents", appEvents); + + registerNodeDisposalCallback((node) => { + // write any cleanup logic here + // such as disposing Bootstrap components attached to the node. + }); } From 6c5d692418ef230d3b6a6ff6d0bf60e393e73700 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Fri, 23 Jan 2026 09:46:31 +0300 Subject: [PATCH 38/46] updated the settings --- docs/setting-up.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/setting-up.md b/docs/setting-up.md index d227f90..539348d 100644 --- a/docs/setting-up.md +++ b/docs/setting-up.md @@ -180,7 +180,9 @@ export function configureApp() { */ registerNodeDisposalCallback((node) => { // Example: Dispose Bootstrap tooltips/popovers - // if (node._bootstrap_tooltip) node._bootstrap_tooltip.dispose(); + // if bootstrap.Tooltip.getInstance(node) { + // bootstrap.Tooltip.getInstance(node).dispose(); + // } }); } From 1b29ea17ba1ef17e61d4bef2919aa471d7acd68d Mon Sep 17 00:00:00 2001 From: levizwannah Date: Fri, 23 Jan 2026 10:15:49 +0300 Subject: [PATCH 39/46] working on documentation --- docs/osm.md | 130 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 123 insertions(+), 7 deletions(-) diff --git a/docs/osm.md b/docs/osm.md index 2fbb350..9cac1f8 100644 --- a/docs/osm.md +++ b/docs/osm.md @@ -24,18 +24,81 @@ The `h` object is a **Proxy**. When you access a property on it (e.g., `h.div`, ## Attributes & Properties -You can pass attributes as the first argument to the tag function if it is an object (and not a DOM node or State). +Attributes can be passed as **objects** at any point in the argument list. OpenScript treats any argument that is an object (and not a DOM Node or State) as an attributes object. ```javascript -h.a( +// Attributes can be anywhere +h.div("Text first", { id: "my-div" }, " More text"); + +// Multiple attributes objects are merged +// Note: Underscores in keys are converted to dashes (data_role -> data-role) +h.div({ id: "main" }, "Content", { data_role: "wrapper" }); +``` + +### Class Merging + +Special attributes like `class` are intelligently handled. If you pass multiple class attributes (in different objects), they are **concatenated** rather than overwritten. + +```javascript +h.div({ class: "btn" }, "Click Me", { class: "btn-primary" }); +// Output:
Click Me
+``` + +### Event Handling & Memory Safety + +> [!WARNING] +> **Avoid `addEventListener`**: Do not use the standard `element.addEventListener` methods on nodes created by OpenScript. Doing so can lead to memory leaks because the framework cannot track and automatically remove these listeners when the component is unmounted. + +Instead, always use the `listeners` attribute object. OpenScript modifies the DOM nodes to include safe `addListener` and `removeListener` methods that integrate with the framework's lifecycle management. + +```javascript +h.button( + { + listeners: { + click: (e) => console.log("Safe click", e), + }, + }, + "Safe Button", +); +``` + +### Extending Node Functionality (`methods`) + +You can attach custom methods to a DOM node at runtime using the `methods` attribute. This is useful for exposing logic that needs to be called externally (e.g., after retrieving the node via `document.getElementById`). + +```javascript +h.div( { - href: "https://example.com", - class: "link primary", - target: "_blank", - id: "main-link", + id: "my-widget", + methods: { + refresh: function () { + this.innerHTML = "Refreshed!"; // 'this' refers to the DOM element + }, + getData: () => ({ id: 1, value: "test" }), + }, }, - "Visit Example", + "Widget Content", ); + +// Usage elsewhere: +const widget = document.getElementById("my-widget"); +if (widget) { + widget.methods().refresh(); +} +``` + +### Inline Function Calls (`h.func`) + +The `h.func` helper formats a function and its arguments as a string, suitable for placement in inline event attributes (like `onclick`). This is how you pass function calls into string-based attributes. + +```javascript +// In a component +render() { + return h.div({ + // Generates: onclick="greet('Levi', 42)" + onclick: h.func("greet", "Levi", 42) + }, "Click to Greet") +} ``` ### Boolean Attributes @@ -74,3 +137,56 @@ Strings and numbers are automatically converted to text nodes. ```javascript h.p("You have ", 5, " notifications."); ``` + +## Logic & Helpers + +OpenScript provides helper functions to handle logic like iterations and conditionals directly within your markup structure. + +### Executing Logic (`h.call`) + +If you need to run arbitrary logic during the creation of the node structure, you can use `h.call(callback)`. The callback should return a valid OSM node (string, element, or array). + +```javascript +h.div( + { class: "container" }, + h.call(() => { + // Perform complex logic here + const date = new Date(); + return h.span(`Created at: ${date.toLocaleTimeString()}`); + }), +); +``` + +### Iteration (`each`) + +The `each` helper iterates over an array or object and returns an array of results. + +```javascript +import { app, each } from "modular-openscriptjs"; + +const items = ["Apple", "Banana", "Cherry"]; + +h.ul( + each(items, (item, index) => { + return h.li({ "data-index": index }, item); + }), +); +``` + +### Conditionals (`ifElse`) + +The `ifElse` helper (or `Utils.ifElse`) allows you to conditionally render content. + +```javascript +import { app, ifElse } from "modular-openscriptjs"; + +const isLoggedIn = true; + +h.div( + ifElse( + isLoggedIn, + () => h.button("Logout"), // True branch (function executed) + h.button("Login"), // False branch (value returned) + ), +); +``` From 3be51a23b2bafa2d26c9c216f6568148b1587532 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Fri, 23 Jan 2026 16:36:57 +0300 Subject: [PATCH 40/46] working on ojs documentation --- docs/events.md | 114 +++++++++++++++++++++++++++++++++++++ docs/mediators.md | 106 ++++++++++++++++++++++++++++++++++ docs/osm.md | 35 ++++++++++++ docs/special-attributes.md | 90 +++++++++++++++++++++++++++++ docs/state.md | 75 ++++++++++++++++++++++++ package.json | 2 +- 6 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 docs/events.md create mode 100644 docs/mediators.md create mode 100644 docs/special-attributes.md create mode 100644 docs/state.md diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..7b0c1f4 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,114 @@ +# Events & Broker + +OpenScript uses a **Broker** to manage communication between components and mediators. This decoupled architecture relies on a structured event system. + +## Event Structure & Registration + +Events are typically defined as a structured "fact" object in `events.js`. This structure allows you to use auto-completion and compile-time checking for event names. + +When you register this object with the broker using `broker.registerEvents(appEvents)`, OpenScript parses it and replaces the leaf values (set to `true`) with the actual namespaced event string. + +```javascript +// events.js (Before Registration) +export const appEvents = { + auth: { + login: true, // Becomes "auth:login" + logout: true, // Becomes "auth:logout" + }, + user: { + is: { + authenticated: true, // Becomes "user:is:authenticated" + }, + }, +}; +``` + +### Registration (ojs.config.js) + +```javascript +import { appEvents } from "./events.js"; + +// ... in configureApp() +broker.registerEvents(appEvents); +container.value("appEvents", appEvents); +``` + +## Using Object Paths + +After registration, you can use the `appEvents` object keys to reference the event strings directly. This prevents typo-related bugs. + +```javascript +// Instead of this: +this.send("auth:login", data); + +// Use this: +this.send(appEvents.auth.login, data); +``` + +## Payloads (`payload` / `EventData`) + +When sending events, it is best practice to wrap your data in a standard **Payload**. OpenScript provides a `payload` or `eData` helper for this. + +A payload consists of: + +- **Message**: The actual data (body). +- **Meta**: Metadata about the event (timestamps, source, etc). + +```javascript +import { payload } from "modular-openscriptjs"; + +// Sending an event with a payload +this.send( + appEvents.auth.login, + payload( + { username: "Levi", id: 1 }, // Message + { timestamp: Date.now() }, // Meta (optional) + ), +); +``` + +### Receiving Payloads + +Listeners created with `$$` (or standard broker listeners) receive these arguments. + +```javascript +async $$auth_login(payloadObj) { + // payloadObj is the message/data directly if spread, + // OR the EventData object depending on how it was sent. + // Generally, OpenScript broker spreads arguments. + + // If sent as above: + // args[0] = { username: "Levi", id: 1 } + // args[1] = { timestamp: ... } +} +``` + +> **Note**: `EventData` is the underlying class, but `payload()` is the convenient helper function to create instances of it. + +## Parsing Received Events + +When an event is received (e.g., in a Mediator), the payload is often a **JSON string**. You must parse it back into an `EventData` object to access the helpers. + +```javascript +import { EventData } from "modular-openscriptjs"; + +// ... in a listener +const eventData = EventData.parse(payloadString); +``` + +### EventData Helper Methods + +The parsed object provides `message` and `meta` properties, each with the following methods: + +- `has(key)`: Checks if a key exists. +- `get(key, defaultValue)`: Gets a value (returns `defaultValue` if missing). +- `put(key, value)`: Sets a value. +- `remove(key)`: Deletes a value. +- `getAll()`: Returns the raw object. + +```javascript +const userId = eventData.message.get("id"); +if (eventData.meta.has("timestamp")) { + // ... +} +``` diff --git a/docs/mediators.md b/docs/mediators.md new file mode 100644 index 0000000..9abf25a --- /dev/null +++ b/docs/mediators.md @@ -0,0 +1,106 @@ +# Mediators + +Mediators act as the bridge between your application's logic (backend/services) and the frontend (components). They facilitate a clean separation of concerns by handling business logic and communicating via the unrelated **Broker**. + +## Concept & Role + +Mediators are **stateless** classes that listen for events, execute logic (like API calls or data processing), and then potentially emit new events. They do not manipulate the DOM directly. + +```javascript +// Example Mediator structure +export default class AuthMediator extends Mediator { + shouldRegister() { + return true; // Control registration logic + } +} +``` + +## Broker Registration + +When you define a Mediator, it is automatically registered with the **Broker** if `shouldRegister()` returns `true`. The broker scans the mediator for special properties to set up event listeners. + +## Event Listening (`$$`) + +The `$$` prefix is used to define event listeners. OpenScript interprets these property names and values to decide which events to listen to. + +### Underscore means "OR" + +If you use an underscore in the method name after `$$`, it acts as an **OR** operator. The method will key off **multiple independent events**. + +```javascript +import { EventData } from "modular-openscriptjs"; + +/* + * Listens for: + * - 'user' event + * - 'login' event + * (NOT 'user:login') + */ +async $$user_login(eventData) { + // Parse the JSON string payload + const data = EventData.parse(eventData); + + console.log("Triggered by 'user' OR 'login' event"); + console.log("User ID:", data.message.get("id")); +} +``` + +### Namespacing (Nested Objects) + +To listen to namespaced events (e.g., `user:login`, `user:logout`), you should use **nested objects**. This structure treats events as facts and allows for organized event definitions. + +```javascript +// Property starts with $$ -> 'auth' namespace +$$auth = { + // Listens for 'auth:login' + login: async (eventData) => { + const data = EventData.parse(eventData); + this.handleLogin(data); + }, + + // Listens for 'auth:logout' + logout: async () => { + this.handleLogout(); + }, + + // Nested further: 'auth:password:reset' + password: { + reset: (email) => { ... } + } +} +``` + +## Creating "Events as Facts" + +It is a best practice to structure your events like facts (`noun:verb` or `subject:predicate:object`). This is typically done in `events.js` and enforced by `broker.requireEventsRegistration(true)`. + +```javascript +// appEvents definition (events.js) +export const appEvents = { + user: { + is: { + authenticated: true, // event: 'user:is:authenticated' + unauthenticated: true, + }, + has: { + ordered: true, // event: 'user:has:ordered' + }, + }, +}; +``` + +## Sending Events + +Mediators can send events using `this.send(event, payload(...))` or `this.broadcast(event, payload(...))`. + +```javascript +import { payload } from "modular-openscriptjs"; + +async $$auth_login(eventData) { + // Validate... + this.send( + "user:is:authenticated", + payload({ userProfile }) + ); +} +``` diff --git a/docs/osm.md b/docs/osm.md index 9cac1f8..e531803 100644 --- a/docs/osm.md +++ b/docs/osm.md @@ -190,3 +190,38 @@ h.div( ), ); ``` + +## Fragments (`h.$` or `h._`) + +Fragments allow you to group multiple elements without adding an extra node to the DOM. This is particularly useful when returning multiple root elements from a component or `h.call`. + +To create a fragment, use `h.$()` or `h._()`. + +> [!IMPORTANT] +> **Single Root Requirement**: Even within a fragment, you must ensure there is a **single top-level element** that acts as the parent for the other elements in that fragment structure. + +```javascript +// Correct Usage +h.$( + h.div( + // Top-level parent in the fragment + h.span("Part 1"), + h.span("Part 2"), + ), +); + +// Incorrect Usage (Multiple top-level siblings) +// h.$ ( +// h.div("Part 1"), +// h.div("Part 2") +// ) +``` + +### Component Wrapper Behavior + +Normally, a Component's markup is automatically wrapped in a custom element (e.g., ``). However, **if a component returns a fragment**, this wrapper is **NOT** created. + +> [!CAUTION] +> **State Reactivity Limitation**: Components that return fragments **cannot react to state changes** efficiently because there is no wrapper element to anchor the reconciler. Use fragments in components primarily for splitting up large render functions or for static content. +> +> **Top-Level Requirement**: While fragments avoid wrappers, your final application structure **Must** typically have a stable top-level element in the final markup for the framework to attach to. diff --git a/docs/special-attributes.md b/docs/special-attributes.md new file mode 100644 index 0000000..47e3733 --- /dev/null +++ b/docs/special-attributes.md @@ -0,0 +1,90 @@ +# Special Attributes + +OpenScript provides several special attributes that control how elements are rendered and how component wrappers are styled. + +## Render Placement Attributes + +These attributes control _where_ and _how_ an element triggers the rendering process relative to a parent. + +### `parent` + +Specifies the DOM element to append the new element to. + +```javascript +const myContainer = document.getElementById("container"); +h.div({ parent: myContainer }, "I am appended to #container"); +``` + +### `resetParent` + +If `true`, clears the `parent` element's content before appending the new element. + +```javascript +h.div({ parent: myContainer, resetParent: true }, "I am the only child now"); +``` + +### `replaceParent` + +If `true`, the new element **replaces** the `parent` element in the DOM. + +```javascript +h.div( + { parent: oldElement, replaceParent: true }, + "I replaced the old element", +); +``` + +### `firstOfParent` + +If `true`, prepends the element to the `parent` instead of appending. + +```javascript +h.div({ parent: myContainer, firstOfParent: true }, "I am first!"); +``` + +## Component Wrapper Attributes + +When a class component is rendered, it is wrapped in a custom element (e.g., ``). You can pass attributes to this wrapper from within the component's render method or when using the component. + +### `c_attr` (Component Attributes) + +Used to pass a group of attributes to the component's wrapper. + +```javascript +// Inside a component's render method +render() { + return h.div( + { + c_attr: { + class: "my-component-wrapper", + "data-theme": "dark", + onclick: "console.log('Wrapper clicked')" + } + }, + "Component Content" + ); +} +// Renders:
...
+``` + +### `$` Prefix Attributes + +Attributes starting with `$` are treated as wrapper attributes. This is a shorthand for `c_attr`. + +```javascript +h.MyComponent({ + $class: "theme-dark", + $id: "main-component", + $style: "border: 1px solid red;", +}); +// Renders: ... +``` + +### `withCAttr` + +If set to `true`, it serializes the `c_attr` object and adds it as a `c-attr` attribute on the element. This is mostly used internally or for debugging. + +```javascript +h.div({ c_attr: { id: 1 }, withCAttr: true }, "..."); +//
...
+``` diff --git a/docs/state.md b/docs/state.md new file mode 100644 index 0000000..c630bd0 --- /dev/null +++ b/docs/state.md @@ -0,0 +1,75 @@ +# State Management + +State management in OpenScript is handled by the `State` class. States are reactive objects that notify listeners when their values change. + +## Creating State + +To create a state, use the `state` helper function (or `State.state`). + +```javascript +import { state } from "modular-openscriptjs"; + +// Create a state with an initial value +const counter = state(0); +const user = state({ name: "Levi", role: "Admin" }); +``` + +## Using State in Components + +There are two primary ways to use state in components: explicit listening and automatic listening via `render`. + +### Automatic Listening (Render Argument) + +If you pass a state object as an argument to a component's `render` method, the component automatically subscribes to that state. When the state changes, the component re-renders. + +```javascript +export default class CounterDisplay extends Component { + render(countState) { + // This component will re-render whenever countState changes + return h.div(`Current Count: ${countState.value}`); + } +} + +// Usage +const myCount = state(0); +h.CounterDisplay(myCount); +``` + +### Manual Listening + +You can also manually listen to state changes using the `.listener()` method, typically in `$_mounted` or constructor, though automatic listening is preferred for UI updates. + +## Global vs Local State + +- **Local State**: Created inside a component (e.g., in the constructor) and used only by that component. +- **Global State**: Created outside components (e.g., in a separate file or `ojs.config.js`) and imported where needed. + +```javascript +// Global state example +export const globalTheme = state("dark"); +``` + +## Anonymous Components (`v`) + +For simple reactive parts of your UI that don't warrant a full class component, use the `v` helper (Value function). It creates an anonymous component that listens to a state. + +```javascript +import { v, state, app } from "modular-openscriptjs"; + +const h = app("h"); +const name = state("World"); + +h.div( + h.h1("Hello"), + // efficiently updates only this text node when 'name' changes + v(name, (s) => ` ${s.value}!`), +); +``` + +The `v` function takes the state as the first argument and a callback as the second. The callback receives the state and should return the markup/string to render. + +## State Helper Methods + +- `state.value`: Get or set the current value. +- `state.fire()`: Manually trigger listeners. +- `state.listener(callback)`: Add a listener. diff --git a/package.json b/package.json index 4328c74..445deb8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "2.0.12", + "version": "2.0.13", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", From 8a69c245759c276d91c33a5d47fa0e9d42eacd8a Mon Sep 17 00:00:00 2001 From: levizwannah Date: Fri, 23 Jan 2026 18:25:52 +0300 Subject: [PATCH 41/46] finished the documentation --- docs/components.md | 73 ++++++++++++++++++++---- docs/context.md | 82 ++++++++++++++++++++++++++ docs/events.md | 11 +--- docs/mediators.md | 10 ++-- docs/router.md | 139 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 290 insertions(+), 25 deletions(-) create mode 100644 docs/context.md create mode 100644 docs/router.md diff --git a/docs/components.md b/docs/components.md index dbc8481..bf52d3a 100644 --- a/docs/components.md +++ b/docs/components.md @@ -129,32 +129,81 @@ OpenScript provides conventions for automatically listening to events based on m #### Component Lifecycle & Emitted Events (`$_`) -Methods starting with `$_` are treated as listeners for events emitted by the component itself (including lifecycle events). +Methods starting with `$_` are treated as listeners for events emitted by the component itself. -- `$_mounted()`: Called when the component is added to the DOM. -- `$_rendered()`: Called when the component is rendered. -- `$_customEvent()`: Listens for `this.emit('customEvent')`. +> [!WARNING] +> **Context Safety**: Inside these listeners, **do not rely on `this`** to access the component instance, as the context might not be bound as expected during execution. +> +> Instead, use the **`componentId`** passed as the first argument and the **`component(id)`** helper to retrieve the safe instance. + +- `$_mounted(componentId)`: Called when the component is added to the DOM. +- `$_rendered(componentId)`: Called when the component is rendered. +- `$_customEvent(componentId, ...args)`: Listens for `this.emit('customEvent')`. + +```javascript +import { component } from "modular-openscriptjs"; + +export default class MyComponent extends Component { + $_mounted(componentId) { + // Correct way to get the instance + const self = component(componentId); + self.handleMount(); + } +} +``` #### Broker Events (`$$`) Methods starting with `$$` are treated as listeners for global events emitted via the **Broker**. -- `$$app_started()`: Listens for `app:started` event (dots/colons usually mapped to underscores). -- `$$user_login()`: Listens for `user:login` event. +**Signature**: `(eventData, eventName)` + +- `eventData`: The JSON stringified payload (needs `EventData.parse()`). +- `eventName`: The string name of the event that triggered this listener. + +- `$$app_started(eventData, event)`: Listens for `app:started`. +- `$$user_login(eventData, event)`: Listens for `user:login`. ```javascript +import { EventData, component } from "modular-openscriptjs"; + export default class UserProfile extends Component { - // Listen to component's own mount event - $_mounted() { - console.log("UserProfile mounted"); + // Listen to global 'auth:logout' event + async $$auth_logout(eventData, event) { + // 1. Parse Data + const data = EventData.parse(eventData); + + // 2. Get Safe Component Instance (if needed) + // Note: Broker listeners in components might not automatically receive componentId + // depending on binding. If 'this' is unsafe, ensure you have a reference. + // However, usually 'this' in Component methods is bound. + // BUT if the user explicitly warned about 'this' in listeners generally: + + console.log(`Received ${event}`); + this.cleanUp(); // 'this' is usually safe in class classes unless stated otherwise, + // but following the pattern: if it's an auto-attached listener, + // verify if it receives componentId? + // The user said: "In those mounted function... use component(id)". + // Mounted functions usually refer to $_. + // Let's assume standard methods $$ might still bind 'this' or + // we should stick to the safe pattern if applicable. + // For now, I will assume $$ methods on Component might still work with 'this', + // but I will respect the standard signature (eventData, event). } +} +``` + +Wait, the user said for `$_` listeners (mounted, etc) specifically regarding `componentId`. "In those mounted function...". +For `$$`, it's a broker listener. +I will implement the `(eventData, event)` signature change. +```javascript // Listen to global 'auth:logout' event from Broker - $$auth_logout(user) { - console.log("User logged out:", user); + async $$auth_logout(eventData, event) { + const data = EventData.parse(eventData); + console.log("User logged out:", data.message.getAll()); this.cleanUp(); } -} ``` ### Inline Attribute Listeners diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 0000000..a7b6476 --- /dev/null +++ b/docs/context.md @@ -0,0 +1,82 @@ +# Context & State Utilities + +Context in OpenScript is a mechanism to share state and data across decoupled components and mediators without prop drilling. It acts as a shared object for storing application state. + +## Concept + +A **Context** is essentially a shared object (instance of `Context` class) that holds `State` instances or other data. It allows: + +- **Decoupling**: Mediators can update context, and Components can read/listen to it without knowing about each other. +- **Shared Data**: accessible via `context('ContextName')`. + +## Defining & Loading Contexts + +You do **not** need to define a special class for your context. You simply register it using `putContext`. + +```javascript +// contexts.js +import { putContext, context, app } from "modular-openscriptjs"; + +// Register a context named "global" +// The second argument is a label/path for debugging or loading structure +putContext(["global"], "AppContext"); + +// Export for usage +export const gc = context("global"); + +// Initialize States in Setup +export function setupContexts() { + gc.states({ + appName: "My App", + isLoggedIn: false, + user: null, + }); + + // Register in Container (optional but recommended) + app.value("gc", gc); +} +``` + +## Using Context + +### Accessing Context + +Use the `context()` helper to retrieve a loaded context. + +```javascript +import { context } from "modular-openscriptjs"; + +const gc = context("global"); +``` + +### Bulk State Initialization + +The `Context` instance has a helper method `states()` to initialize multiple states at once. + +```javascript +// Initialize multiple states +gc.states({ + isLoading: true, + data: [], + error: null, +}); +``` + +## Best Practices + +### Context vs Component State + +- **Context**: Use for global data (User session, Theme, Shopping Cart) or data shared between broad sections of the app. +- **Component State**: Use for local UI behavior (Modal open/close, Form input temporary values). + +### Handling Large Lists + +> [!WARNING] +> **Do not store massive arrays in Context State** if they are only for display (e.g., Infinite Scroll data). + +For large datasets: + +1. **Do not put them in a reactive state**. +2. **Mediators** should handle fetching and posting data. +3. **Components** should perform "GET" operations to retrieve this data directly (or through a non-reactive service) when needed, rather than listing to a state that holds 1000s of objects. +4. Use methods like `replaceParent` or specialized logic to append DOM nodes efficiently instead of re-rendering the entire list via state change. diff --git a/docs/events.md b/docs/events.md index 7b0c1f4..e2e4be7 100644 --- a/docs/events.md +++ b/docs/events.md @@ -72,14 +72,9 @@ this.send( Listeners created with `$$` (or standard broker listeners) receive these arguments. ```javascript -async $$auth_login(payloadObj) { - // payloadObj is the message/data directly if spread, - // OR the EventData object depending on how it was sent. - // Generally, OpenScript broker spreads arguments. - - // If sent as above: - // args[0] = { username: "Levi", id: 1 } - // args[1] = { timestamp: ... } +async $$auth_login(eventData, event) { + // eventData is the JSON string payload + // event is the specific event string that triggered this listener } ``` diff --git a/docs/mediators.md b/docs/mediators.md index 9abf25a..67c5393 100644 --- a/docs/mediators.md +++ b/docs/mediators.md @@ -36,11 +36,11 @@ import { EventData } from "modular-openscriptjs"; * - 'login' event * (NOT 'user:login') */ -async $$user_login(eventData) { +async $$user_login(eventData, event) { // Parse the JSON string payload const data = EventData.parse(eventData); - console.log("Triggered by 'user' OR 'login' event"); + console.log(`Triggered by '${event}'`); console.log("User ID:", data.message.get("id")); } ``` @@ -53,13 +53,13 @@ To listen to namespaced events (e.g., `user:login`, `user:logout`), you should u // Property starts with $$ -> 'auth' namespace $$auth = { // Listens for 'auth:login' - login: async (eventData) => { + login: async (eventData, event) => { const data = EventData.parse(eventData); this.handleLogin(data); }, // Listens for 'auth:logout' - logout: async () => { + logout: async (eventData, event) => { this.handleLogout(); }, @@ -96,7 +96,7 @@ Mediators can send events using `this.send(event, payload(...))` or `this.broadc ```javascript import { payload } from "modular-openscriptjs"; -async $$auth_login(eventData) { +async $$auth_login(eventData, event) { // Validate... this.send( "user:is:authenticated", diff --git a/docs/router.md b/docs/router.md new file mode 100644 index 0000000..b207237 --- /dev/null +++ b/docs/router.md @@ -0,0 +1,139 @@ +# Router + +The OpenScript Router manages navigation within your single-page application. It supports named routes, parameters, chaining, and route grouping. + +## Defining Routes + +You configure routes in `ojs.config.js` (or wherever you configure your app) using the `router` service. + +### Basic Route + +Use `router.on(path, action, [name])` to define a route. + +```javascript +import { app } from "modular-openscriptjs"; + +const router = app("router"); + +router.on( + "/", + () => { + // Render Home Component + h.app(h.HomeComponent()); + }, + "home", +); // Optional name "home" +``` + +### Handling Route Changes + +It's common to define a helper (like `appRender`) to handle swapping active components in your root element. + +```javascript +import { app, dom } from "modular-openscriptjs"; + +const h = app("h"); +const rootElement = dom.id("app-root"); + +// Helper to swap components +const appRender = (component) => { + return h.App(component, { + parent: rootElement, + resetParent: router.reset, // Uses router state + reconcileParent: true, // Enables DOM diffing (smoother animations) + }); +}; + +router.on("/about", () => { + appRender(h.AboutComponent()); +}); +``` + +### Chaining Routes + +You can chain method calls to define multiple routes cleanly. + +```javascript +router + .on("/about", () => h.app(h.About()), "about") + .on("/contact", () => h.app(h.Contact()), "contact"); +``` + +### Multiple Paths for One Action (`orOn`) + +If you want multiple paths to trigger the same action (e.g., legacy URLs), use `orOn`. + +```javascript +// Both /login and /signin run the same action +router.orOn( + ["/login", "/signin"], + () => h.app(h.Login()), + ["login", "signin"], // Optional names for each path respectively +); +``` + +## Route Parameters + +Dynamic segments are defined with curly braces `{paramName}`. + +```javascript +router.on( + "/user/{id}", + () => { + // Access parameter via router.params + const userId = router.params.id; + h.app(h.UserProfile({ id: userId })); + }, + "user.profile", +); +``` + +## Route Groups (`prefix`) + +You can group routes under a common path prefix. + +```javascript +router.prefix("/admin").group(() => { + // URL: /admin/dashboard + router.on("/dashboard", () => ..., "admin.dashboard"); + + // URL: /admin/users + router.on("/users", () => ..., "admin.users"); +}); +``` + +## Navigation + +To navigate programmatically, use `router.to(pathOrName, params)`. + +```javascript +// Navigate by path +router.to("/about"); + +// Navigate by name (Recommended) +router.to("user.profile", { id: 42 }); + +// Navigate by name with query strings (if param keys don't match route params) +router.to("search", { q: "openscript" }); // /search?q=openscript +``` + +## Checking Current Route + +Use `router.is(nameOrPath)` to check the active route (useful for active menu states). + +```javascript +if (router.is("home")) { + // We are on the home page +} +``` + +## Configuration + +- `router.basePath('/app')`: Sets a base path for all routes. +- `router.default(action)`: Sets the 404/Default action if no route is found. + +```javascript +router.default(() => { + h.app(h.NotFoundComponent()); +}); +``` From eea99c7791f51257630a692d9eb7f1efcacb6d68 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Fri, 23 Jan 2026 22:04:23 +0300 Subject: [PATCH 42/46] working on documentation --- docs/components.md | 6 ++ docs/container.md | 117 +++++++++++++++++++++++++++++++++++++ docs/helpers.md | 134 +++++++++++++++++++++++++++++++++++++++++++ docs/mediators.md | 28 ++++++++- package.json | 2 +- src/utils/helpers.js | 1 - 6 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 docs/container.md create mode 100644 docs/helpers.md diff --git a/docs/components.md b/docs/components.md index bf52d3a..d7db224 100644 --- a/docs/components.md +++ b/docs/components.md @@ -43,6 +43,12 @@ ojs(MyComponent); 4. **`ojs(MyComponent)`**: Registers the component with the framework. 5. **Passing Arguments**: The `render` method receives `...args` which can contain parent elements, attributes, or other data passed during rendering. Always spread `...args` in your root element or handle them appropriately. +> [!IMPORTANT] +> **Registration is Required Before Use** +> You **MUST** call `ojs(YourComponent)` to register the component in the IoC container. This **MUST** happen before you try to use the component (e.g., `h.YourComponent()`). +> +> The most common pattern is to call `ojs(YourComponent)` at the very end of your component file, ensuring it is registered as soon as the file is imported. + ## Functional Components Functional components are simpler and are best used for presentational components that don't require complex state management or lifecycle hooks. diff --git a/docs/container.md b/docs/container.md new file mode 100644 index 0000000..2071cbe --- /dev/null +++ b/docs/container.md @@ -0,0 +1,117 @@ +# IoC Container & `app()` Service + +OpenScript uses an **Inversion of Control (IoC) Container** to manage dependencies and global services. This promotes loose coupling and makes your application easier to test and maintain. + +## The `app()` Helper + +The most common way to interact with the container is via the `app()` helper function. + +### Accessing Services + +You can retrieve any registered service by passing its name to `app()`. + +```javascript +import { app } from "modular-openscriptjs"; + +// Get the Router instance +const router = app("router"); + +// Get the Broker +const broker = app("broker"); + +// Get a context (if registered) +const globalContext = app("gc"); +``` + +### Accessing the Container + +Calling `app()` without arguments returns the **Container** instance itself. This is useful when you need to register new services or values. + +```javascript +const container = app(); + +// Register a new value +container.value("myConfig", { apiKey: "12345" }); +``` + +## Registering Services + +You typically register services in your `ojs.config.js` or a setup file. + +### `container.value(name, value)` + +Registers a constant value or an existing instance. This is the most common method for configuration or pre-instantiated classes. + +```javascript +import { app } from "modular-openscriptjs"; +import { appEvents } from "./events.js"; + +// Registering appEvents so it can be resolved anywhere +app().value("appEvents", appEvents); +``` + +### `container.singleton(name, Class, dependencies)` + +Registers a class as a singleton. The container will create **one** instance the first time it is resolved and return that same instance forever. + +```javascript +// Register API Service +app().singleton("api", ApiService); + +// Later... +const api = app("api"); // Creates instance +const api2 = app("api"); // Returns same instance +``` + +### `container.transient(name, Class, dependencies)` + +Registers a class as transient. The container will create a **new** instance every time it is resolved. + +```javascript +app().transient("logger", Logger); +``` + +### `container.factory(name, factoryFunction)` + +Registers a factory function. The function is called to produce the value. + +```javascript +app().factory("timestamp", () => Date.now()); +``` + +## Resolving Services + +While `app('name')` is the shortcut, you can also use `container.resolve('name')`. + +```javascript +const myService = app().resolve("myService", "defaultValue"); +``` + +### Dependency Injection + +When defining services that depend on others, you can pass an array of dependency names. + +```javascript +class UserService { + constructor(api, broker) { + this.api = api; + this.broker = broker; + } +} + +// Register UserService with dependencies 'api' and 'broker' +app().singleton("userService", UserService, ["api", "broker"]); + +// When resolving, container auto-injects "api" and "broker" +const userService = app("userService"); +``` + +## Core Services + +The following services are registered by default: + +- `"h"`: The Markup Engine (Proxy) +- `"router"`: The Router instance +- `"broker"`: The Event Broker +- `"repository"`: Internal Component Repository +- `"contextProvider"`: The Context Provider diff --git a/docs/helpers.md b/docs/helpers.md new file mode 100644 index 0000000..80a3990 --- /dev/null +++ b/docs/helpers.md @@ -0,0 +1,134 @@ +# Helper Functions + +OpenScript provides a set of global helper functions and utilities to simplify common tasks, DOM manipulation, and framework interaction. + +## Logic Helpers + +These helpers are available globally (e.g., `window.ifElse`) and can be used directly in your code or templates. + +### `ifElse(condition, trueValue, falseValue)` + +Evaluates a condition and returns one of two values. If the values are functions, they are executed. + +```javascript +// Basic usage +const status = ifElse(isOnline, "Online", "Offline"); + +// With functions (lazy evaluation) +const result = ifElse( + isValid, + () => complexCalculation(), + () => "Invalid", +); +``` + +### `coalesce(value1, value2)` + +Returns the first non-null/undefined value. Handy for defaults. + +```javascript +const name = coalesce(userInput, "Guest"); +``` + +### `each(iterable, callback)` + +Safely iterates over arrays or objects. Returns an array of results. + +```javascript +// Array +each([1, 2, 3], (num) => console.log(num)); + +// Object +each({ a: 1, b: 2 }, (val, key) => console.log(key, val)); +``` + +### `lazyFor(array, callback)` + +Iterates over an array asynchronously using `setTimeout(..., 0)` to avoid blocking the main thread during large operations. + +```javascript +lazyFor(hugeArray, (item) => { + // Process item without freezing UI +}); +``` + +--- + +## DOM Utilities (`dom`) + +The `dom` object provides shortcuts for common DOM operations. + +### Selection + +- **`dom.id(id)`**: wrapper for `document.getElementById`. +- **`dom.get(selector, parent?)`**: wrapper for `querySelector`. +- **`dom.all(selector, parent?)`**: wrapper for `querySelectorAll`. +- **`dom.byClass(className, parent?)`**: wrapper for `getElementsByClassName`. + +### Manipulation + +- **`dom.create(type)`**: wrapper for `document.createElement`. +- **`dom.put(html, element, append = false)`**: Sets `innerHTML`. +- **`dom.clear(element)`**: Clears `innerHTML`. +- **`dom.disable(element)` / `dom.enable(element)`**: Toggles `disabled` attribute. + +### Positioning + +- **`dom.centerInside(container, element)`**: Centers an absolutely positioned element within a container. + +--- + +## Framework Helpers + +### `app(serviceName?)` + +Access the IoC Container. + +- `app("router")`: Get Router. +- `app("broker")`: Get Event Broker. +- `app()`: Get the Container instance itself. + +### `component(id)` + +Retrieves a Component instance by its unique ID (UID). +Useful in event listeners where you have the UID but not the instance. + +```javascript +const myComp = component(123); +myComp?.setState(newState); +``` + +### `context(name)` & `putContext(name, value)` + +Shortcuts for the Context API. + +- `context("theme")`: Get the "theme" context. +- `putContext("theme", "dark")`: Define/Update the "theme" context. + +### `state(initialValue)` + +Creates a new State object. + +```javascript +const count = state(0); +``` + +### `v(state, callback)` + +Creates an "Anonymous Component" that updates when the bound state changes. + +```javascript +// Renders text that updates automatically +h.div( + {}, + v(countState, (val) => `Count is: ${val}`), +); +``` + +### `ojs(...classes)` + +The main entry point to run the application runner. + +```javascript +ojs(AppClass); +``` diff --git a/docs/mediators.md b/docs/mediators.md index 67c5393..4602adb 100644 --- a/docs/mediators.md +++ b/docs/mediators.md @@ -7,14 +7,38 @@ Mediators act as the bridge between your application's logic (backend/services) Mediators are **stateless** classes that listen for events, execute logic (like API calls or data processing), and then potentially emit new events. They do not manipulate the DOM directly. ```javascript -// Example Mediator structure +// AuthMediator.js export default class AuthMediator extends Mediator { shouldRegister() { - return true; // Control registration logic + return true; } } ``` +### Best Practice: `boot.js` + +For Mediators, it is best practice to have a dedicated `boot.js` (or `mediators.js`) file that imports and registers them all. This ensures they are registered early in the application lifecycle. + +```javascript +// src/boot.js +import { ojs } from "modular-openscriptjs"; +import AuthMediator from "./mediators/AuthMediator"; +import CartMediator from "./mediators/CartMediator"; + +export default function boot() { + ojs(AuthMediator, CartMediator); +} +``` + +Then, in your `main.js`: + +```javascript +// src/main.js +import boot from "./boot"; + +boot(); // Registers all mediators +``` + ## Broker Registration When you define a Mediator, it is automatically registered with the **Broker** if `shouldRegister()` returns `true`. The broker scans the mediator for special properties to set up event listeners. diff --git a/package.json b/package.json index 445deb8..2f333c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modular-openscriptjs", - "version": "2.0.13", + "version": "2.0.14", "description": "OpenScriptJs Framework - A lightweight, reactive JavaScript framework for building modern web applications", "type": "module", "main": "./dist/modular-openscriptjs.umd.js", diff --git a/src/utils/helpers.js b/src/utils/helpers.js index b0909d9..1e90cad 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -23,7 +23,6 @@ export function namespace(name) { export function cleanUpNode(node) { node.__openscript_cleanup__?.(); - node.__eventListeners = null; } /** From 117a4b3ad69f2b7849b1aa3d0c82df17e2c56ea4 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Fri, 23 Jan 2026 22:27:24 +0300 Subject: [PATCH 43/46] working on docs --- README.md | 1381 ++++++++++++----------------------------------------- 1 file changed, 299 insertions(+), 1082 deletions(-) diff --git a/README.md b/README.md index 3419964..4d6a4e3 100644 --- a/README.md +++ b/README.md @@ -4,1282 +4,499 @@ A modern, modular, event-driven JavaScript framework built for scalability and m ## 🚀 Key Features -- **IoC Container**: Centralized dependency management using a robust container and `app()` helper -- **Reactive State**: Proxy-based state management with automatic UI updates -- **Event-Driven**: Powerful `Broker` and `Mediator` pattern for decoupled communication -- **Component-Based**: Class-based components with lifecycle hooks and functional stateless components -- **Fluent Router**: Expressive, fluent API for client-side routing with nested routes -- **Lightweight**: Zero runtime dependencies, pure JavaScript -- **TypeScript Ready**: Built with modern ES6+ features -- **Vite Plugin**: Build tools for minification-safe production builds +- **IoC Container**: Centralized dependency management using a robust container and `app()` helper. +- **Reactive State**: Proxy-based state management with automatic UI updates using `state()`. +- **Event-Driven**: Powerful `Broker` and `Mediator` pattern for decoupled communication. +- **Component-Based**: Class-based components with lifecycle hooks and functional stateless components. +- **OpenScript Markup (OSM)**: A powerful DSL for generating HTML without a Virtual DOM overhead. +- **Fluent Router**: Expressive, fluent API for client-side routing with nested routes and grouping. +- **Context API**: Share state globally without prop drilling. +- **Lightweight**: Zero runtime dependencies, pure JavaScript. +- **Vite Integration**: Optimized build process with automatic component discovery. --- -## 📦 Installation +## 📚 Table of Contents -### Using npm/yarn +1. [Installation & Setup](#1-installation--setup) +2. [Core Architecture](#2-core-architecture) +3. [Components](#3-components) +4. [OpenScript Markup (OSM)](#4-openscript-markup-osm) +5. [State Management](#5-state-management) +6. [Context API](#6-context-api) +7. [Events & Broker](#7-events--broker) +8. [Mediators](#8-mediators) +9. [Routing](#9-routing) +10. [IoC Container](#10-ioc-container) +11. [Helper Functions](#11-helper-functions) -```bash -npm install modular-openscriptjs -# or -yarn add modular-openscriptjs -``` +--- + +## 1. Installation & Setup -### Create a New Project +### Installation -The fastest way to get started is using the project scaffolding tool: +Install the package via npm: ```bash -npx create-ojs-app my-app -cd my-app -npm run dev +npm install modular-openscriptjs ``` -**Available Templates:** - -- `basic` - Clean starter with vanilla CSS and simple structure -- `tailwind` - Pre-configured with TailwindCSS and responsive design -- `bootstrap` - Bootstrap 5 integration with utility classes - ---- - -## ⚡ Quick Start +### Project Structure (Entry Point) + +1. **index.html**: Create your app shell. + +```html + + + + My OpenScript App + + +
+ + + +``` -Create a simple counter application in seconds. +2. **src/main.js**: Initialize the app. ```javascript -import { Component, app, state, ojs } from "modular-openscriptjs"; +import "./ojs.config.js"; // Import config first +import { app } from "modular-openscriptjs"; +import { setupRoutes } from "./routes.js"; +import { setupContexts } from "./contexts.js"; -const h = app("h"); +async function init() { + setupContexts(); // Initialize global state -// 1. Define State -const counter = state(0); + const rootElement = document.getElementById("app-root"); + setupRoutes(rootElement); // Configure routes -// 2. Create Component -class CounterApp extends Component { - render() { - return h.div( - h.h1(`Count: ${counter.value}`), - h.button({ onclick: () => counter.value++ }, "Increment") - ); - } + // Start the router + app("router").listen(); } -// 3. Run Application -ojs(CounterApp); +init(); ``` ---- - -## 🏗️ Architecture Overview - -OpenScript is built around a central **IoC Container**. Instead of importing global instances, you access core services via the `app()` helper. +### Vite Configuration -### Core Services +Create `vite.config.js` to enable the OpenScript plugin, which handles tasks like component auto-discovery. -The framework provides these built-in services through the container: +```javascript +import { defineConfig } from "vite"; +import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; -| Service | Access | Description | -| ------------------- | ------------------------ | --------------------------------------------- | -| **h** | `app('h')` | Hyperscript builder for creating DOM elements | -| **broker** | `app('broker')` | Event bus for application-wide communication | -| **router** | `app('router')` | Client-side routing manager | -| **contextProvider** | `app('contextProvider')` | Manages application contexts | -| **mediatorManager** | `app('mediatorManager')` | Handles mediator registration | -| **loader** | `app('loader')` | Auto-loader for dynamic imports | +export default defineConfig({ + plugins: [ + openScriptComponentPlugin({ + // componentsDir: 'src/components' // Optional + }), + ], +}); +``` -### The `app()` Helper +### OpenScript Configuration (`ojs.config.js`) -The `app()` function is your gateway to the container: +Configure core services like the Router and Broker in a dedicated file. ```javascript -import { app } from "modular-openscriptjs"; +import { app, registerNodeDisposalCallback } from "modular-openscriptjs"; +import { appEvents } from "./events.js"; -// Access services -const h = app("h"); const router = app("router"); const broker = app("broker"); -// Register custom values -app().value("apiUrl", "https://api.example.com"); -app().value("config", { debug: true, theme: "dark" }); +export function configureApp() { + // Router Config + router.runtimePrefix(""); + router.basePath(""); + + // Broker Config + broker.CLEAR_LOGS_AFTER = 30000; + broker.TIME_TO_GC = 10000; + broker.removeStaleEvents(); + + // Enable logs in dev + if (/^(127\.0\.0\.1|localhost|.*\.test)$/.test(router.url().hostname)) { + broker.withLogs(true); + } + + // Strict Event Registration + broker.requireEventsRegistration(true); + broker.registerEvents(appEvents); + + // Register appEvents in container for easy access + app().value("appEvents", appEvents); -// Access registered values -const apiUrl = app("apiUrl"); -const config = app("config"); + // Cleanup callback for third-party libs + registerNodeDisposalCallback((node) => { + // e.g. clean up tooltips + }); +} + +configureApp(); ``` -**Benefits:** +--- + +## 2. Core Architecture + +OpenScript is built around an **Inversion of Control (IoC) Container**. Instead of importing global instances directly, you access core services via the `app()` helper. -- Single source of truth for dependencies -- Easy to mock services for testing -- Runtime service replacement -- Better code organization +| Service | Access | Description | +| :---------------- | :----------------------- | :--------------------------------------------- | +| **Markup Engine** | `app('h')` | Helper proxy for creating DOM elements. | +| **Router** | `app('router')` | Manages navigation and URL handling. | +| **Broker** | `app('broker')` | Central event bus for decoupled communication. | +| **Context** | `app('contextProvider')` | Manages global application state contexts. | --- -## 🧩 Components +## 3. Components -Components are the building blocks of your UI. They can be **Class-based** (stateful) or **Functional** (stateless). +Components are the building blocks of your UI. ### Class Components -Extend `Component` to create stateful components with lifecycle hooks. +Extend `Component` for stateful logic and lifecycle hooks. ```javascript -import { Component, app, state } from "modular-openscriptjs"; +import { Component, app, ojs, state } from "modular-openscriptjs"; const h = app("h"); -class UserProfile extends Component { +export default class Counter extends Component { constructor() { super(); - this.username = state("Guest"); - this.avatar = state("/default-avatar.png"); + this.count = state(0); } - // Lifecycle: Called when component is mounted to DOM - async mount() { - console.log("Component mounted!"); - // Fetch user data, set up listeners, etc. - await this.loadUserData(); - } - - // Lifecycle: Called when component is removed from DOM - unmount() { - console.log("Component unmounted!"); - // Clean up listeners, timers, etc. - } - - async loadUserData() { - const apiUrl = app("apiUrl"); - const response = await fetch(`${apiUrl}/user`); - const data = await response.json(); - this.username.value = data.name; - this.avatar.value = data.avatar; - } - - render(...args) { + render() { return h.div( - { class: "user-profile" }, - h.img({ src: this.avatar.value, alt: "Avatar" }), - h.h2(this.username.value), - ...args + h.h1(`Count: ${this.count.value}`), + h.button({ onclick: () => this.count.value++ }, "Increment"), ); } } -``` -**Component Lifecycle:** +// CRITICAL: Register before usage! +ojs(Counter); +``` -1. `constructor()` - Initialize state and properties -2. `mount()` - Component added to DOM (async supported) -3. `render()` - Generate component markup -4. `unmount()` - Component removed from DOM +> [!IMPORTANT] +> **Registration Required**: You **MUST** call `ojs(YourComponent)` to register the component in the IoC container. This allows the framework to instantiate it and manage its lifecycle. ### Functional Components -Simple functions that return markup. Great for presentational UI. +Simple functions for stateless UI. ```javascript -const Button = (text, onclick, variant = "primary") => { - return h.button( - { - class: `btn btn-${variant}`, - onclick, - }, - text - ); -}; - -const Card = (title, content) => { - return h.div( - { class: "card" }, - h.div({ class: "card-header" }, h.h3(title)), - h.div({ class: "card-body" }, content) - ); -}; - -// Usage -h.div( - Button("Click Me", () => console.log("Clicked!"), "success"), - Card("Welcome", h.p("This is a card component")) -); +export default function Card({ title, content }) { + return h.div({ class: "card" }, h.h2(title), h.p(content)); +} ``` -### The `h` Builder (Hyperscript) +### Event Listening -OpenScript uses a hyperscript-like helper `h` to build DOM elements efficiently. +Use the `listeners` object for safe event binding. ```javascript -const h = app("h"); - -// Basic element -h.div("Hello World"); - -// With attributes -h.div({ class: "container", id: "main" }, "Content"); - -// Nested elements -h.div( - { class: "card" }, - h.header(h.h1("Title")), - h.section(h.p("Paragraph 1"), h.p("Paragraph 2")), - h.footer(h.small("Footer text")) +h.button( + { + class: "btn", + listeners: { + click: (e) => console.log("Clicked!"), + mouseover: () => console.log("Hovered"), + }, + }, + "Click Me", ); - -// Arrays of elements -h.ul(...["Apple", "Banana", "Cherry"].map((fruit) => h.li(fruit))); ``` -**Special Attributes:** - -| Attribute | Description | Example | -| --------------------------- | ----------------------------- | ------------------------------ | -| `listeners` | Object of event listeners | `{ listeners: { click: fn } }` | -| `parent` | DOM element to append to | `{ parent: document.body }` | -| `resetParent` | Clear parent before appending | `{ resetParent: true }` | -| `component` | Attach component instance | `{ component: myComponent }` | -| `onclick`, `onchange`, etc. | Direct event handlers | `{ onclick: () => {} }` | - -**Component Methods as Event Handlers:** +#### Special Lifecycle Methods -Prefix component methods with `$_` to use them directly in markup: +- `$_mounted(componentId)`: Component added to DOM. +- `$_rendered(componentId)`: Component rendered. -```javascript -class MyComponent extends Component { - $_handleClick(event) { - console.log("Button clicked", event); - } - - render() { - return h.button({ onclick: this.$_handleClick }, "Click Me"); - } -} -``` +> **Note**: Use `component(componentId)` inside these methods to get a safe reference to the instance if `this` is not bound correctly. --- -## 🔄 State Management +## 4. OpenScript Markup (OSM) -State is reactive by default. When state changes, any component using that state automatically re-renders. +OSM is a DSL for generating HTML using the `h` proxy. -### Basic State +### Basic Usage ```javascript -import { state } from "modular-openscriptjs"; - -// Create reactive state -const count = state(0); - -// Read value -console.log(count.value); // 0 - -// Update value -> triggers UI updates -count.value++; - -// Listen to changes -count.listener((stateObj) => { - console.log("New value:", stateObj.value); - console.log("Previous value:", stateObj.previousValue); -}); - -// Conditional updates -if (count.value > 10) { - count.value = 0; -} -``` - -### State in Components - -```javascript -class TodoList extends Component { - constructor() { - super(); - this.todos = state([]); - this.filter = state("all"); // "all", "active", "completed" - } - - addTodo(text) { - this.todos.value = [ - ...this.todos.value, - { id: Date.now(), text, completed: false }, - ]; - } - - toggleTodo(id) { - this.todos.value = this.todos.value.map((todo) => - todo.id === id ? { ...todo, completed: !todo.completed } : todo - ); - } - - removeTodo(id) { - this.todos.value = this.todos.value.filter((todo) => todo.id !== id); - } - - get filteredTodos() { - switch (this.filter.value) { - case "active": - return this.todos.value.filter((t) => !t.completed); - case "completed": - return this.todos.value.filter((t) => t.completed); - default: - return this.todos.value; - } - } +const h = app("h"); - render() { - return h.div( - h.input({ - type: "text", - placeholder: "Add todo...", - onkeypress: (e) => { - if (e.key === "Enter" && e.target.value) { - this.addTodo(e.target.value); - e.target.value = ""; - } - }, - }), - h.div( - ...["all", "active", "completed"].map((filter) => - h.button( - { - class: this.filter.value === filter ? "active" : "", - onclick: () => (this.filter.value = filter), - }, - filter.toUpperCase() - ) - ) - ), - h.ul( - ...this.filteredTodos.map((todo) => - h.li( - { class: todo.completed ? "completed" : "" }, - h.input({ - type: "checkbox", - checked: todo.completed, - onchange: () => this.toggleTodo(todo.id), - }), - h.span(todo.text), - h.button({ onclick: () => this.removeTodo(todo.id) }, "×") - ) - ) - ) - ); - } -} +h.div({ id: "main", class: "container" }, h.h1("Title"), h.p("Paragraph text")); ``` -### Contexts +### Attributes -Contexts group related states and make them globally available across your application. +Pass attributes as objects. Multiple objects are merged. Class strings are concatenated. ```javascript -import { context, putContext, Component, app } from "modular-openscriptjs"; +h.div({ class: "btn" }, "Text", { class: "btn-primary", "data-id": 1 }); +//
Text
+``` -const h = app("h"); +### Special Attributes -// 1. Register Context -putContext("user", "UserContext"); -putContext("app", "AppContext"); - -// 2. Initialize States -const uc = context("user"); -uc.states({ - name: "Guest", - email: "", - isLoggedIn: false, - role: "guest", -}); +- **`parent`**: Append directly to a DOM node. +- **`resetParent`**: Clear parent before appending. +- **`replaceParent`**: Replace the parent node. +- **`listeners`**: Safe event listeners object. +- **`c_attr` / `$`-prefix**: Pass attributes to the component wrapper (e.g., `$class: "wrapper-class"`). -const ac = context("app"); -ac.states({ - theme: "light", - language: "en", - notifications: [], -}); +### Fragments -// 3. Use in Components -class UserProfile extends Component { - render() { - // Component auto-updates when uc.name or uc.email changes - return h.div( - h.h2(`Welcome, ${uc.name.value}!`), - h.p(`Email: ${uc.email.value}`), - h.p(`Role: ${uc.role.value}`), - uc.isLoggedIn.value - ? h.button({ onclick: this.logout }, "Logout") - : h.button({ onclick: this.login }, "Login") - ); - } - - login() { - uc.name.value = "John Doe"; - uc.email.value = "john@example.com"; - uc.isLoggedIn.value = true; - uc.role.value = "admin"; - } - - logout() { - uc.name.value = "Guest"; - uc.email.value = ""; - uc.isLoggedIn.value = false; - uc.role.value = "guest"; - } -} +Use `h.$()` or `h._()` to group elements without a wrapper. -// 4. Access from anywhere -class ThemeToggle extends Component { - toggleTheme() { - ac.theme.value = ac.theme.value === "light" ? "dark" : "light"; - } - - render() { - return h.button( - { onclick: this.toggleTheme.bind(this) }, - `Theme: ${ac.theme.value}` - ); - } -} +```javascript +h.$(h.li("Item 1"), h.li("Item 2")); ``` -**Best Practices:** - -- Use contexts for truly global state -- Keep related state together -- Use meaningful context names -- Initialize all states upfront +> **Warning**: Fragments cannot be reactive roots for components. --- -## 📡 Event System +## 5. State Management -OpenScript uses a **Broker/Mediator** pattern to decouple business logic from UI components. +OpenScript uses `State` objects. When a state's `.value` changes, bound UI updates automatically. -### 1. Register Events - -Define your events in a structured object: +### creating State ```javascript -import { app } from "modular-openscriptjs"; - -const broker = app("broker"); - -const $e = { - user: { - login: true, - logout: true, - registered: true, - profileUpdated: true, - }, - todo: { - added: true, - removed: true, - completed: true, - uncompleted: true, - }, - app: { - initialized: true, - themeChanged: true, - errorOccurred: true, - }, -}; +import { state } from "modular-openscriptjs"; -// Register all events -broker.registerEvents($e); +const count = state(0); +const user = state({ name: "Guest" }); ``` -Event names become namespaced: `user:login`, `todo:added`, etc. - -### 2. Mediators (Business Logic Handlers) - -Mediators handle business logic and respond to events: - -```javascript -import { Mediator, payload, app } from "modular-openscriptjs"; - -const broker = app("broker"); - -class AuthMediator extends Mediator { - // The '$$' prefix auto-registers these as event listeners - $$user = { - // Listens to 'user:login' - login: (ed, eventName) => { - const data = Utils.parsePayload(ed); - console.log("User logged in:", data.message); - - // Perform business logic - this.saveToLocalStorage(data.message); - this.updateAnalytics("login", data.message); - - // Emit subsequent events - broker.send( - "user:profileUpdated", - payload({ - userId: data.message.id, - }) - ); - }, - - // Listens to 'user:logout' - logout: (ed, eventName) => { - console.log("User logged out"); - this.clearLocalStorage(); - this.updateAnalytics("logout"); - }, +### Using in Components - // Listens to 'user:registered' - registered: (ed, eventName) => { - const data = Utils.parsePayload(ed); - this.sendWelcomeEmail(data.message.email); - this.createUserProfile(data.message); - }, - }; +1. **Auto-Listen**: Pass state to `render()`. + ```javascript + render(count) { return h.div(count.value); } + ``` +2. **Anonymous Component (`v`)**: Update specific parts of the DOM. - $$app = { - errorOccurred: (ed, eventName) => { - const error = Utils.parsePayload(ed).message; - this.logErrorToService(error); - this.showNotification("An error occurred", "error"); - }, - }; + ```javascript + import { v } from "modular-openscriptjs"; - saveToLocalStorage(user) { - localStorage.setItem("user", JSON.stringify(user)); - } - - clearLocalStorage() { - localStorage.removeItem("user"); - } - - updateAnalytics(action, data = {}) { - // Send to analytics service - console.log("Analytics:", action, data); - } + h.div( + "Static content ", + v(count, (c) => `Dynamic: ${c.value}`), + ); + ``` - sendWelcomeEmail(email) { - // API call to send email - console.log("Sending welcome email to:", email); - } - - createUserProfile(user) { - // API call to create profile - console.log("Creating profile for:", user); - } - - logErrorToService(error) { - // Send to error tracking service - console.error("Error logged:", error); - } - - showNotification(message, type) { - // Show UI notification - broker.send("ui:showNotification", payload({ message, type })); - } -} - -// Instantiate mediator (auto-registers all listeners) -new AuthMediator(); -``` - -### 3. Multi-Event Listeners - -Listen to multiple events with a single handler using underscore: +--- -```javascript -class NotificationMediator extends Mediator { - $$user = { - // Triggers on BOTH 'user:login' AND 'user:logout' - login_logout: (ed, eventName) => { - console.log(`User authentication event: ${eventName}`); - this.showNotification(`User ${eventName.split(":")[1]} event occurred`); - }, - }; -} -``` +## 6. Context API -### 4. Emitting Events +Share state globally across components and mediators. -Send events from anywhere in your application: +### Setup ```javascript -import { app, payload } from "modular-openscriptjs"; +// contexts.js +import { putContext, context, app } from "modular-openscriptjs"; -const broker = app("broker"); +// 1. Define +putContext("global", "GlobalContext"); -// Simple event -broker.send( - "user:login", - payload({ - id: 123, - username: "john_doe", - }) -); +// 2. Export +export const gc = context("global"); -// With metadata -broker.send( - "todo:added", - payload( - { todo: { id: 1, text: "Buy milk" } }, // message - { timestamp: Date.now(), source: "ui" } // meta - ) -); +// 3. Initialize +export function setupContexts() { + gc.states({ + theme: "dark", + currentUser: null, + }); -// Error event -broker.send( - "app:errorOccurred", - payload({ - error: new Error("Network failure"), - context: "API request", - }) -); + app().value("gc", gc); +} ``` -### 5. Component Event Listeners - -Components can also listen to events: +### Usage ```javascript -class Dashboard extends Component { - async mount() { - // Subscribe to events - app("broker").on("user:login", (ed) => { - const user = Utils.parsePayload(ed).message; - console.log("Dashboard received login:", user); - this.refreshData(); - }); - - app("broker").on("todo:added", (ed) => { - this.updateTodoCount(); - }); - } +import { gc } from "./contexts.js"; - refreshData() { - // Reload dashboard data - } +// Read/Write +gc.theme.value = "light"; - updateTodoCount() { - // Update todo counter - } - - render() { - return h.div({ class: "dashboard" }, "Dashboard Content"); - } +// In Component +render() { + return h.div(`Theme is: ${gc.theme.value}`); } ``` --- -## 🛣️ Routing - -The router uses a fluent API for defining routes with support for parameters, groups, and middleware. - -### Basic Routing - -```javascript -import { app, context } from "modular-openscriptjs"; - -const router = app("router"); -const h = app("h"); - -// Define routes -router.on( - "/", - () => { - h.HomePage({ parent: document.body, resetParent: true }); - }, - "home" // Route name -); - -router.on( - "/about", - () => { - h.AboutPage({ parent: document.body, resetParent: true }); - }, - "about" -); +## 7. Events & Broker -router.on( - "/contact", - () => { - h.ContactPage({ parent: document.body, resetParent: true }); - }, - "contact" -); +The **Broker** manages application-wide events. -// Start listening to route changes -router.listen(); -``` +### Defining Events (`events.js`) -### Route Parameters +Define events as a "fact" structure. ```javascript -// Single parameter -router.on( - "/users/{id}", - () => { - const userId = router.params.id; - console.log("Viewing user:", userId); - h.UserProfile(userId, { parent: document.body, resetParent: true }); +export const appEvents = { + auth: { + login: true, // "auth:login" + logout: true, // "auth:logout" }, - "users.view" -); - -// Multiple parameters -router.on( - "/posts/{postId}/comments/{commentId}", - () => { - const { postId, commentId } = router.params; - console.log("Post:", postId, "Comment:", commentId); - h.CommentView(postId, commentId, { - parent: document.body, - resetParent: true, - }); + user: { + updated: true, // "user:updated" }, - "posts.comments.view" -); +}; ``` -### Grouped Routes - -```javascript -// Group with prefix -router.prefix("admin").group(() => { - router.on( - "/", - () => { - h.AdminDashboard({ parent: document.body, resetParent: true }); - }, - "admin.dashboard" - ); - - router.on( - "/users", - () => { - h.AdminUsers({ parent: document.body, resetParent: true }); - }, - "admin.users" - ); - - router.on( - "/settings", - () => { - h.AdminSettings({ parent: document.body, resetParent: true }); - }, - "admin.settings" - ); -}); -// Routes: /admin, /admin/users, /admin/settings - -// Nested groups -router.prefix("api").group(() => { - router.prefix("v1").group(() => { - router.on( - "/users", - () => { - /* ... */ - }, - "api.v1.users" - ); - router.on( - "/posts", - () => { - /* ... */ - }, - "api.v1.posts" - ); - }); -}); -// Routes: /api/v1/users, /api/v1/posts -``` +### Registration -### Default Route +In `ojs.config.js`: ```javascript -// Redirect to home if no route matches -router.default(() => router.to("home")); - -// Or show 404 page -router.default(() => { - h.NotFoundPage({ parent: document.body, resetParent: true }); -}); +broker.registerEvents(appEvents); ``` -### Programmatic Navigation - -```javascript -// Navigate to a named route -router.to("users.view"); - -// Navigate with parameters -router.push("/users/123"); +### Payloads -// Navigate with state -router.to("profile", { userId: 456 }); - -// Go back -router.back(); -``` - -### Router Base Path +Use `payload()` to create standardized event messages. ```javascript -// Set base path for deployment in subdirectories -router.basePath("/my-app"); +import { payload } from "modular-openscriptjs"; -// Now routes are relative to /my-app -router.on("/home", () => { ... }); // Actual path: /my-app/home +broker.send(appEvents.auth.login, payload({ userId: 123 }, { source: "ui" })); ``` --- -## 📚 Examples +## 8. Mediators -### Complete Todo App +Mediators bridge UI and Logic. They are stateless classes that listen to Broker events. ```javascript -import { - Component, - app, - state, - context, - putContext, - ojs, -} from "modular-openscriptjs"; - -const h = app("h"); +// AuthMediator.js +import { Mediator } from "modular-openscriptjs"; +import { EventData } from "modular-openscriptjs"; -// Setup context -putContext("todos", "TodoContext"); -const tc = context("todos"); -tc.states({ - todos: [], - filter: "all", -}); - -class TodoApp extends Component { - constructor() { - super(); - this.newTodoText = state(""); - } +export default class AuthMediator extends Mediator { + shouldRegister() { return true; } - addTodo() { - if (this.newTodoText.value.trim()) { - tc.todos.value = [ - ...tc.todos.value, - { - id: Date.now(), - text: this.newTodoText.value, - completed: false, - }, - ]; - this.newTodoText.value = ""; - } - } - - toggleTodo(id) { - tc.todos.value = tc.todos.value.map((todo) => - todo.id === id ? { ...todo, completed: !todo.completed } : todo - ); - } - - deleteTodo(id) { - tc.todos.value = tc.todos.value.filter((todo) => todo.id !== id); - } + // Listen to 'auth:login' + async $$auth_login(eventData, event) { + const data = EventData.parse(eventData); // Parse payload + console.log("User logged in:", data.message); - get filteredTodos() { - switch (tc.filter.value) { - case "active": - return tc.todos.value.filter((t) => !t.completed); - case "completed": - return tc.todos.value.filter((t) => t.completed); - default: - return tc.todos.value; - } - } - - get stats() { - const total = tc.todos.value.length; - const completed = tc.todos.value.filter((t) => t.completed).length; - const active = total - completed; - return { total, completed, active }; - } - - render() { - const stats = this.stats; - - return h.div( - { class: "todo-app" }, - h.header( - h.h1("My Todos"), - h.p(`${stats.active} active, ${stats.completed} completed`) - ), - h.div( - { class: "todo-input" }, - h.input({ - type: "text", - placeholder: "What needs to be done?", - value: this.newTodoText.value, - oninput: (e) => (this.newTodoText.value = e.target.value), - onkeypress: (e) => { - if (e.key === "Enter") this.addTodo(); - }, - }), - h.button({ onclick: () => this.addTodo() }, "Add") - ), - h.div( - { class: "filters" }, - ...["all", "active", "completed"].map((filter) => - h.button( - { - class: tc.filter.value === filter ? "active" : "", - onclick: () => (tc.filter.value = filter), - }, - filter.charAt(0).toUpperCase() + filter.slice(1) - ) - ) - ), - h.ul( - { class: "todo-list" }, - ...this.filteredTodos.map((todo) => - h.li( - { class: todo.completed ? "completed" : "" }, - h.input({ - type: "checkbox", - checked: todo.completed, - onchange: () => this.toggleTodo(todo.id), - }), - h.span({ class: "todo-text" }, todo.text), - h.button( - { - class: "delete-btn", - onclick: () => this.deleteTodo(todo.id), - }, - "×" - ) - ) - ) - ) - ); + this.send("user:updated", payload({ ... })); } } - -ojs(TodoApp); ``` -### Form Validation Example - -```javascript -class SignupForm extends Component { - constructor() { - super(); - this.formData = state({ - email: "", - password: "", - confirmPassword: "", - }); - this.errors = state({}); - this.isSubmitting = state(false); - } - - updateField(field, value) { - this.formData.value = { - ...this.formData.value, - [field]: value, - }; - // Clear error when user types - this.clearError(field); - } - - clearError(field) { - const newErrors = { ...this.errors.value }; - delete newErrors[field]; - this.errors.value = newErrors; - } - - validate() { - const errors = {}; - const { email, password, confirmPassword } = this.formData.value; - - if (!email) { - errors.email = "Email is required"; - } else if (!/^\S+@\S+\.\S+$/.test(email)) { - errors.email = "Invalid email format"; - } - - if (!password) { - errors.password = "Password is required"; - } else if (password.length < 8) { - errors.password = "Password must be at least 8 characters"; - } +### Registration (`boot.js` Pattern) - if (password !== confirmPassword) { - errors.confirmPassword = "Passwords do not match"; - } +It is best practice to register all mediators in a `boot.js` file. - this.errors.value = errors; - return Object.keys(errors).length === 0; - } - - async handleSubmit(e) { - e.preventDefault(); - - if (!this.validate()) return; - - this.isSubmitting.value = true; - - try { - const response = await fetch("/api/signup", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(this.formData.value), - }); - - if (response.ok) { - alert("Signup successful!"); - // Reset form - this.formData.value = { email: "", password: "", confirmPassword: "" }; - } else { - this.errors.value = { form: "Signup failed. Please try again." }; - } - } catch (error) { - this.errors.value = { form: error.message }; - } finally { - this.isSubmitting.value = false; - } - } - - renderField(label, field, type = "text") { - const error = this.errors.value[field]; - return h.div( - { class: "form-group" }, - h.label(label), - h.input({ - type, - value: this.formData.value[field], - oninput: (e) => this.updateField(field, e.target.value), - class: error ? "error" : "", - }), - error && h.span({ class: "error-message" }, error) - ); - } +```javascript +// boot.js +import { ojs } from "modular-openscriptjs"; +import AuthMediator from "./mediators/AuthMediator"; - render() { - return h.form( - { onsubmit: this.handleSubmit.bind(this) }, - h.h2("Sign Up"), - this.errors.value.form && - h.div({ class: "error-banner" }, this.errors.value.form), - this.renderField("Email", "email", "email"), - this.renderField("Password", "password", "password"), - this.renderField("Confirm Password", "confirmPassword", "password"), - h.button( - { - type: "submit", - disabled: this.isSubmitting.value, - }, - this.isSubmitting.value ? "Submitting..." : "Sign Up" - ) - ); - } +export default function boot() { + ojs(AuthMediator); } ``` -Check the `examples/` directory for more detailed usage patterns: - -- **`basic-usage.js`**: Simple counter app -- **`advanced-features.js`**: Fragments and manual context registration -- **`component-example.js`**: Component communication -- **`event-handling.js`**: Mediators and event patterns -- **`state-example.js`**: Deep dive into state patterns -- **`context-state-example.js`**: Global state management - --- -## 🛠️ Advanced Topics - -### Manual Context Registration +## 9. Routing -If you need to register a context instance manually (e.g., for testing): +The Router uses a fluent API. ```javascript -import { app } from "modular-openscriptjs"; - -class MyContext { - theme = state("dark"); - language = state("en"); -} +const router = app("router"); +const h = app("h"); -app("contextProvider").map.set("Theme", new MyContext()); -``` +// Basic Route +router.on("/", () => { + h.HomePage({ parent: document.body, resetParent: true }); +}, "home"); + +// Route with Params +router.on("/user/{id}", () => { + const id = router.params.id; + // render user profile... +}, "user.profile"); + +// Groups +router.prefix("/admin").group(() => { + router.on("/dashboard", ...); + router.on("/settings", ...); +}); -### Customizing the Runner +// Navigation +router.to("user.profile", { id: 42 }); -The `ojs()` function is a wrapper around `Runner`. You can use `Runner` directly for more control: +// Default/404 +router.default(() => router.to("home")); +``` -```javascript -import { Runner } from "modular-openscriptjs"; +--- -const runner = new Runner(); -runner.run(MyComponent); -``` +## 10. IoC Container -### Fragment Support +Manage dependencies via `app()`. -Use fragments to return multiple elements without a wrapper: +### Accessing Services ```javascript -class MyComponent extends Component { - render() { - return h.$( - // or h._ - h.h1("Title"), - h.p("Paragraph 1"), - h.p("Paragraph 2") - ); - } -} +const router = app("router"); +const container = app(); ``` -### Lazy Loading Components +### Registering Services ```javascript -import { app } from "modular-openscriptjs"; +// Singleton (Single instance) +app().singleton("api", ApiService); -const loader = app("loader"); +// Transient (New instance every time) +app().transient("logger", Logger); -// Lazy load a component -const LazyComponent = await loader.req("components.LazyComponent"); +// Value (Constant/Instance) +app().value("config", { apiUrl: "..." }); ``` ---- - -## 🔧 Configuration - -### Vite Plugin - -For production builds with proper minification: +### Dependency Injection ```javascript -// vite.config.js -import { defineConfig } from "vite"; -import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; - -export default defineConfig({ - plugins: [openScriptComponentPlugin()], - build: { - target: "es2015", - minify: "terser", - }, -}); -``` - -### TypeScript Support - -While OpenScript is written in vanilla JavaScript, it works well with TypeScript: - -```typescript -import { Component, app, state, State } from "modular-openscriptjs"; - -interface Todo { - id: number; - text: string; - completed: boolean; -} - -class TodoList extends Component { - todos: State; - - constructor() { - super(); - this.todos = state([]); +class UserService { + constructor(api) { + this.api = api; } - - // ... rest of implementation } -``` - ---- - -## 📈 Best Practices - -### State Management - -- ✅ Use contexts for truly global state -- ✅ Keep component state local when possible -- ✅ Use computed properties (getters) for derived state -- ❌ Don't mutate state directly, always reassign - -### Component Design - -- ✅ Keep components small and focused -- ✅ Use functional components for presentational UI -- ✅ Leverage lifecycle hooks appropriately -- ❌ Don't mix business logic with UI logic - -### Event System - -- ✅ Use mediators for business logic -- ✅ Keep event names well-organized -- ✅ Document your event structure -- ❌ Don't emit events in tight loops - -### Performance -- ✅ Use `resetParent` to clear before rendering -- ✅ Minimize state updates -- ✅ Use fragments to avoid unnecessary wrapper elements -- ❌ Don't create new functions in render (use component methods) - ---- - -## 🐛 Troubleshooting - -### Component Not Re-rendering - -- Ensure state is updated via `.value` assignment -- Check that state is actually being used in `render()` -- Verify component is properly mounted - -### Events Not Firing - -- Confirm events are registered with broker -- Check event names match exactly (remember the namespace) -- Ensure mediators are instantiated - -### Router Not Working - -- Call `router.listen()` after defining routes -- Check browser console for errors -- Verify route paths are correct - ---- - -## 📄 License - -MIT © Levi Kamara Zwannah - ---- - -## 🤝 Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. - -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +// Inject 'api' service into UserService +app().singleton("userService", UserService, ["api"]); +``` --- -## 🔗 Links +## 11. Helper Functions -- [GitHub Repository](https://github.com/yourusername/modular-openscriptjs) -- [Issue Tracker](https://github.com/yourusername/modular-openscriptjs/issues) -- [npm Package](https://www.npmjs.com/package/modular-openscriptjs) -- [Documentation](https://github.com/yourusername/modular-openscriptjs/wiki) - ---- +Global utilities available in `window` or via import. -**Built with ❤️ using OpenScript** +- **`ifElse(condition, trueVal, falseVal)`**: Logic helper. +- **`coalesce(v1, v2)`**: Null coalescing. +- **`each(list, cb)`**: Safe iteration. +- **`dom.id(id)` / `dom.get(sel)`**: DOM query shortcuts. +- **`component(id)`**: Get component instance by ID. From 419720b96ec8e4d4accc55c575460a11265e45b7 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Fri, 23 Jan 2026 22:38:07 +0300 Subject: [PATCH 44/46] working on final readme --- README.md | 281 +++++++++++++++++- docs/{setting-up.md => 1-setting-up.md} | 0 docs/{container.md => 10-container.md} | 0 docs/{helpers.md => 11-helpers.md} | 0 ...EGRATION.md => 12-TAILWIND_INTEGRATION.md} | 0 docs/{notes.md => 13-notes.md} | 0 docs/{components.md => 2-components.md} | 0 docs/{osm.md => 3-osm.md} | 0 ...-attributes.md => 4-special-attributes.md} | 0 docs/{state.md => 5-state.md} | 0 docs/{context.md => 6-context.md} | 0 docs/{router.md => 7-router.md} | 0 docs/{events.md => 8-events.md} | 0 docs/{mediators.md => 9-mediators.md} | 0 14 files changed, 270 insertions(+), 11 deletions(-) rename docs/{setting-up.md => 1-setting-up.md} (100%) rename docs/{container.md => 10-container.md} (100%) rename docs/{helpers.md => 11-helpers.md} (100%) rename docs/{TAILWIND_INTEGRATION.md => 12-TAILWIND_INTEGRATION.md} (100%) rename docs/{notes.md => 13-notes.md} (100%) rename docs/{components.md => 2-components.md} (100%) rename docs/{osm.md => 3-osm.md} (100%) rename docs/{special-attributes.md => 4-special-attributes.md} (100%) rename docs/{state.md => 5-state.md} (100%) rename docs/{context.md => 6-context.md} (100%) rename docs/{router.md => 7-router.md} (100%) rename docs/{events.md => 8-events.md} (100%) rename docs/{mediators.md => 9-mediators.md} (100%) diff --git a/README.md b/README.md index 4d6a4e3..f583ba4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ npm install modular-openscriptjs + + My OpenScript App @@ -80,6 +82,8 @@ async function init() { init(); ``` +_Note: In visual applications, you typically define routes that render components into the `rootElement`._ + ### Vite Configuration Create `vite.config.js` to enable the OpenScript plugin, which handles tasks like component auto-discovery. @@ -91,7 +95,8 @@ import { openScriptComponentPlugin } from "modular-openscriptjs/plugin"; export default defineConfig({ plugins: [ openScriptComponentPlugin({ - // componentsDir: 'src/components' // Optional + // Optional: Configure components directory if different from 'src/components' + // componentsDir: 'src/components' }), ], }); @@ -99,46 +104,300 @@ export default defineConfig({ ### OpenScript Configuration (`ojs.config.js`) -Configure core services like the Router and Broker in a dedicated file. +Create an `ojs.config.js` file in your project root. This file is where you configure the core services of OpenScript, such as the Router and Broker. ```javascript import { app, registerNodeDisposalCallback } from "modular-openscriptjs"; -import { appEvents } from "./events.js"; +import { appEvents } from "./events.js"; // We will create this in the next section + +/*---------------------------------- + | Do OpenScript Configurations Here + |---------------------------------- +*/ const router = app("router"); const broker = app("broker"); export function configureApp() { - // Router Config + /*----------------------------------- + | Set the global runtime prefix. + | This prefix will be appended + | to every path before resolution. + | So ensure when defining routes, + | you have it as the main prefix. + |------------------------------------ +*/ router.runtimePrefix(""); + + /**---------------------------------- + * + * Set the default route path here + * ---------------------------------- + */ router.basePath(""); - // Broker Config + /*-------------------------------- + | Set the logs clearing interval + | for the broker to remove stale + | events. (milliseconds) + |-------------------------------- +*/ broker.CLEAR_LOGS_AFTER = 30000; + + /*-------------------------------- + | Set how old an event must be + | to be deleted from the broker's + | event log during logs clearing + |-------------------------------- +*/ broker.TIME_TO_GC = 10000; + + /*------------------------------------------- + | Start the garbage + | collector for the broker + |------------------------------------------- +*/ broker.removeStaleEvents(); - // Enable logs in dev + /*------------------------------------------ + | Should the broker display events + | in the console as they are fired + |------------------------------------------ +*/ if (/^(127\.0\.0\.1|localhost|.*\.test)$/.test(router.url().hostname)) { - broker.withLogs(true); + broker.withLogs(false); // Enable logs for development } - // Strict Event Registration + /** + * --------------------------------------------- + * Should the broker require events registration. + * This ensures that only registered events + * can be listened to and fire by the broker. + * --------------------------------------------- + */ broker.requireEventsRegistration(true); + + /** + * --------------------------------------------- + * Register events with the broker + * --------------------------------------------- + */ + broker.registerEvents(appEvents); - // Register appEvents in container for easy access + /** + * --------------------------------------------- + * Register core services in IoC container + * --------------------------------------------- + */ app().value("appEvents", appEvents); - // Cleanup callback for third-party libs + /** + * --------------------------------------------- + * Node Disposal Callback + * --------------------------------------------- + * Use this to clean up external library instances + * attached to DOM nodes when they are removed. + */ registerNodeDisposalCallback((node) => { - // e.g. clean up tooltips + // Example: Dispose Bootstrap tooltips/popovers + // if bootstrap.Tooltip.getInstance(node) { + // bootstrap.Tooltip.getInstance(node).dispose(); + // } }); } +// execute configuration configureApp(); ``` +> **Note**: `registerNodeDisposalCallback` is crucial for preventing memory leaks when using third-party libraries that attach instances to DOM elements (like Bootstrap, Tippy.js, etc.). The callback **MUST** be synchronous and stateless. + +> **Note**: In the configuration above, we are using `appEvents` imported from `events.js`. We will cover the creation of `events.js` and how to handle events in the subsequent sections. + +### Define Application Events + +OpenScript uses a centralized event broker. It's best practice to define all your application events in a single file, typically `events.js` (or `src/events.js`). + +If you configured `broker.requireEventsRegistration(true)` in your `ojs.config.js`, only events defined here and registered will be allowed. + +Create a `src/events.js` file: + +```javascript +/** + * Application Events + * Structure: Nested object where keys become namespaced event names + * Example: app.started becomes "app:started" + * todo.added -> "todo:added" + */ +export const appEvents = { + app: { + started: true, + ready: true, + }, + + // Example for a Todo App + todo: { + added: true, + deleted: true, + completed: true, + + // Nested events + needs: { + refresh: true, + }, + }, + + ui: { + modal: { + opened: true, + closed: true, + }, + }, +}; +``` + +This structure allows you to use `appEvents.todo.added` to refer to the event in your code, providing strict typing and avoiding magic strings. + +### Configure Contexts + +Contexts are used to manage state and share data across your application. Create an `ojs.contexts.js` file (or `src/ojs.contexts.js`) to initialize them. + +```javascript +import { context, putContext, app } from "modular-openscriptjs"; + +// 1. Register Context Keys +// This reserves the keys for your contexts. +// The second argument is a provider name (can be arbitrary for simple apps). +putContext(["global", "todo"], "AppContext"); + +// 2. Export Context Instances for usage in other files +export const gc = context("global"); +export const tc = context("todo"); + +// 3. Setup Function to Initialize States +export function setupContexts() { + // Initialize Global Context + gc.states({ + appName: "My OpenScript App", + isAuthenticated: false, + user: null, + }); + + // Initialize Todo Context + tc.states({ + todos: [], + filter: "all", + }); + + // Add listeners if needed + tc.todos.listener((state) => { + console.log("Todos updated:", state.value); + }); + + // 4. Register in IoC Container (Optional but recommended) + // This allows you to retrieve contexts using app("gc") anywhere. + app().value("gc", gc); + app().value("tc", tc); + + console.log("Contexts initialized"); +} +``` + +Don't forget to import and call `setupContexts()` in your `main.js`: + +```javascript +// in main.js +import "./ojs.config.js"; // 1. Configuration first +import { setupContexts } from "./ojs.contexts.js"; // 2. Then Contexts + +// ... other imports + +setupContexts(); // Initialize contexts before mounting app +``` + +### Configure Routes + +Create an `ojs.routes.js` file (or `src/ojs.routes.js`) to define your application's routes. + +This file typically handles two things: + +1. Importing your page components. +2. Defining the routes in the router. +3. Defining a render helper to mount components into the root element. + +```javascript +import { app, ojs } from "modular-openscriptjs"; +import App from "./components/App.js"; // Your main layout component +import HomePage from "./components/HomePage.js"; + +// Register components with the Markup Engine if they aren't auto-discovered +ojs(App, HomePage); + +export function setupRoutes() { + const router = app("router"); + const h = app("h"); + + // Get the root element (assuming it was set in Global Context or we get it directly) + const rootElement = document.getElementById("app-root"); + + /** + * Helper to render a component to the root element. + * We use h.App (or your layout component) to wrap the page. + * + * @param {Component} component - The page component to render. + */ + const appRender = (component) => { + // h.App refers to the App component registered above. + // 'parent' option tells the engine where to render this component. + return h.App(component, { + parent: rootElement, + resetParent: true, // Clear the parent content before rendering + reconcileParent: true, // Efficiently update the DOM if possible + }); + }; + + // Define Routes + + // Default route (redirects to /home) + router.default(() => router.to("home")); + + router.on( + "/", + () => { + appRender(h.HomePage()); + }, + "home", + ); + + // Example of another route + // router.on("/about", () => appRender(h.AboutPage()), "about"); + + console.log("Routes configured"); +} +``` + +Now, update your `main.js` to include the routes: + +```javascript +// in main.js +import "./ojs.config.js"; +import { setupContexts } from "./ojs.contexts.js"; +import { setupRoutes } from "./ojs.routes.js"; // Import routes setup + +// ... + +setupContexts(); + +// Setup routes before starting the router +setupRoutes(); + +const router = app("router"); +router.listen(); +``` + +You are now set up with the basic structure of an OpenScript application! + --- ## 2. Core Architecture diff --git a/docs/setting-up.md b/docs/1-setting-up.md similarity index 100% rename from docs/setting-up.md rename to docs/1-setting-up.md diff --git a/docs/container.md b/docs/10-container.md similarity index 100% rename from docs/container.md rename to docs/10-container.md diff --git a/docs/helpers.md b/docs/11-helpers.md similarity index 100% rename from docs/helpers.md rename to docs/11-helpers.md diff --git a/docs/TAILWIND_INTEGRATION.md b/docs/12-TAILWIND_INTEGRATION.md similarity index 100% rename from docs/TAILWIND_INTEGRATION.md rename to docs/12-TAILWIND_INTEGRATION.md diff --git a/docs/notes.md b/docs/13-notes.md similarity index 100% rename from docs/notes.md rename to docs/13-notes.md diff --git a/docs/components.md b/docs/2-components.md similarity index 100% rename from docs/components.md rename to docs/2-components.md diff --git a/docs/osm.md b/docs/3-osm.md similarity index 100% rename from docs/osm.md rename to docs/3-osm.md diff --git a/docs/special-attributes.md b/docs/4-special-attributes.md similarity index 100% rename from docs/special-attributes.md rename to docs/4-special-attributes.md diff --git a/docs/state.md b/docs/5-state.md similarity index 100% rename from docs/state.md rename to docs/5-state.md diff --git a/docs/context.md b/docs/6-context.md similarity index 100% rename from docs/context.md rename to docs/6-context.md diff --git a/docs/router.md b/docs/7-router.md similarity index 100% rename from docs/router.md rename to docs/7-router.md diff --git a/docs/events.md b/docs/8-events.md similarity index 100% rename from docs/events.md rename to docs/8-events.md diff --git a/docs/mediators.md b/docs/9-mediators.md similarity index 100% rename from docs/mediators.md rename to docs/9-mediators.md From 4820fd5241a1ab6da5af5b964001d9331d506791 Mon Sep 17 00:00:00 2001 From: levizwannah Date: Sat, 24 Jan 2026 00:52:59 +0300 Subject: [PATCH 45/46] finished the main readme --- README.md | 987 +++++++++++++++++++++++++++++++++++++------ docs/2-components.md | 23 +- docs/9-mediators.md | 1 + 3 files changed, 873 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index f583ba4..079b0a4 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ Components are the building blocks of your UI. ### Class Components -Extend `Component` for stateful logic and lifecycle hooks. +Class components extend `Component` and provide state management, lifecycle hooks, and event handling. ```javascript import { Component, app, ojs, state } from "modular-openscriptjs"; @@ -432,10 +432,21 @@ export default class Counter extends Component { this.count = state(0); } - render() { + // Lifecycle Methods (prefixed with $_) + // CRITICAL: Always use component(id) to get the instance safely. + $_mounted(id) { + console.log(`Counter ${id} mounted`); + } + + increment() { + this.count.value++; + } + + render(...args) { return h.div( h.h1(`Count: ${this.count.value}`), - h.button({ onclick: () => this.count.value++ }, "Increment"), + h.button({ onclick: this.method("increment") }, "Increment"), + ...args, ); } } @@ -449,221 +460,693 @@ ojs(Counter); ### Functional Components -Simple functions for stateless UI. +Simple functions for stateless UI. They receive `props` (arguments) and return markup. ```javascript -export default function Card({ title, content }) { - return h.div({ class: "card" }, h.h2(title), h.p(content)); +export default function Card(title, content, ...args) { + return h.div({ class: "card" }, h.h2(title), h.p(content), ...args); } ``` +### Naming Conventions + +- **Classes/Functions**: PascalCase (e.g., `UserProfile`). +- **Files**: PascalCase (e.g., `UserProfile.js`). +- **Tags**: Kebab-case (automatically derived, e.g., ``). + ### Event Listening -Use the `listeners` object for safe event binding. +There are multiple ways to listen to events in a component. + +#### 1. DOM Events (`listeners`) + +Use the `listeners` object in attributes for safe binding. This is the **preferred** method. ```javascript h.button( { - class: "btn", listeners: { - click: (e) => console.log("Clicked!"), - mouseover: () => console.log("Hovered"), + // Use anonymous functions for safety + click: (e) => this.increment(), }, }, "Click Me", ); ``` -#### Special Lifecycle Methods +#### 2. Lifecycle Events (`$_`) + +Methods prefixed with `$_` hook into the component's lifecycle and internal events. + +- **`$_mounted(componentId)`**: Called when the component is added to the DOM. +- **`$_rendered(componentId)`**: Called after the component renders. + +> [!WARNING] +> **Context Safety**: Inside `$_` methods, **do not rely on `this`** directly. The context might not be bound as expected. +> Instead, use the `component(id)` helper: +> +> ```javascript +> import { component } from "modular-openscriptjs"; +> +> $_mounted(id) { +> const self = component(id); // Safe instance access +> self.initData(); +> } +> ``` + +#### 3. Broker Events (`$$`) + +Listen to global application events dispatched via the Broker. Methods prefixed with `$$` are automatically registered as listeners. + +**Signature**: `(eventData, eventName)` + +- `eventData`: The JSON stringified payload (must be parsed). +- `eventName`: The specific event name triggered. + +```javascript +import { EventData } from "modular-openscriptjs"; + +export default class UserProfile extends Component { + // Listen for 'auth:login' event + async $$auth_login(eventData, eventName) { + // 1. Parse the payload + const data = EventData.parse(eventData); + + console.log("User Logged In:", data.message.get("userId")); + } +} +``` + +#### 4. Inline Attribute Listeners -- `$_mounted(componentId)`: Component added to DOM. -- `$_rendered(componentId)`: Component rendered. +For attributes that expect a string script (like `onclick`), use `this.method()`. -> **Note**: Use `component(componentId)` inside these methods to get a safe reference to the instance if `this` is not bound correctly. +```javascript +h.button({ onclick: this.method("handleClick") }, "Click"); +``` --- ## 4. OpenScript Markup (OSM) -OSM is a DSL for generating HTML using the `h` proxy. +OpenScript Markup (OSM) is a powerful, JavaScript-based Domain Specific Language (DSL) for generating HTML. At its core is the `h` proxy service, which translates property accessors into DOM elements. ### Basic Usage +You access OSM via the `h` service from the IoC container. + ```javascript +import { app } from "modular-openscriptjs"; + const h = app("h"); -h.div({ id: "main", class: "container" }, h.h1("Title"), h.p("Paragraph text")); +// Simple element +// The proxy intercepts 'div' and creates a
element +const myDiv = h.div({ id: "main" }, "Hello World"); ``` -### Attributes +### Attributes & Properties -Pass attributes as objects. Multiple objects are merged. Class strings are concatenated. +Attributes are passed as properties in an object argument. Flexible placement of arguments allows you to pass attributes anywhere in the function call. ```javascript -h.div({ class: "btn" }, "Text", { class: "btn-primary", "data-id": 1 }); -//
Text
+// Attributes can be first, middle, or last +h.div("Text Content", { class: "text-lg" }, h.span("Child")); ``` -### Special Attributes +#### Class Merging + +OSM intelligently handles the `class` attribute. If you pass multiple objects containing `class`, they are **concatenated** rather than overwritten. This is incredibly useful for conditional styling. + +```javascript +h.button({ class: "btn" }, "Click Me", { class: "btn-primary" }); +// Result: +``` + +#### Event Handling (`listeners`) + +> [!WARNING] +> **Memory Safety**: Do not use standard `addEventListener` on nodes created by OpenScript, as it can lead to memory leaks when components are unmounted. + +Instead, usage the `listeners` object attribute. The framework tracks these listeners and automatically removes them during the component disposal phase. + +```javascript +h.button( + { + listeners: { + click: (e) => console.log("Clicked!", e), + mouseover: (e) => console.log("Hovered", e), + }, + }, + "Safe Button", +); +``` + +#### Extended Functionality (`methods`) + +You can attach custom methods directly to a DOM node using the `methods` attribute. This is useful for exposing API-like functionality on specific elements. + +```javascript +h.div({ + id: "my-widget", + methods: { + refresh: function () { + this.innerHTML = "Refreshed!"; + }, + }, +}); + +// Later usage: +document.getElementById("my-widget").methods().refresh(); +``` + +#### Inline String Handlers (`h.func`) + +For attributes that require a string function call (like `onclick` or `onchange`), use `h.func` to format the call correctly with arguments. + +```javascript +h.button( + { + onclick: h.func("myGlobalHandler", 123, "test"), + }, + "Click Me", +); +// Renders: onclick="myGlobalHandler(123, 'test')" +``` -- **`parent`**: Append directly to a DOM node. -- **`resetParent`**: Clear parent before appending. -- **`replaceParent`**: Replace the parent node. -- **`listeners`**: Safe event listeners object. -- **`c_attr` / `$`-prefix**: Pass attributes to the component wrapper (e.g., `$class: "wrapper-class"`). +### Logic Helpers -### Fragments +OSM provides built-in helpers to handle logic directly within your markup structure. -Use `h.$()` or `h._()` to group elements without a wrapper. +#### `h.call(callback)` + +Execute arbitrary logic during the render process. The callback should return a valid Node, string, or array. + +```javascript +h.div( + h.call(() => { + const date = new Date(); + return h.span(`Rendered at: ${date.toLocaleTimeString()}`); + }), +); +``` + +#### Iteration (`each`) + +Iterate over arrays or objects efficiently. + +```javascript +import { each } from "modular-openscriptjs"; + +const items = ["Apple", "Banana"]; + +h.ul(each(items, (item, index) => h.li(item))); +``` + +#### Conditionals (`ifElse`) + +Render content based on boolean conditions. + +```javascript +import { ifElse } from "modular-openscriptjs"; + +h.div(ifElse(isLoggedIn, h.button("Logout"), h.button("Login"))); +``` + +### Fragments (`h.$` / `h._`) + +Fragments allow you to group multiple elements without adding an extra node to the DOM. ```javascript h.$(h.li("Item 1"), h.li("Item 2")); ``` -> **Warning**: Fragments cannot be reactive roots for components. +> [!IMPORTANT] +> **Single Root Requirement**: Even when using fragments, your overall component structure or logic block must eventually anchor to a single parent element in the DOM tree. +> **No Wrapper**: Components returning a fragment are **NOT** wrapped in a custom element (e.g., ``). This means they cannot easily hold local state or use lifecycle hooks that depend on the wrapper. Use fragments primarily for static content or splitting up render logic. + +### Special Attributes + +OpenScript reserves specific attributes to control rendering behavior and component wrapping. + +#### Render Placement + +Control where an element is injected relative to a target parent. + +| Attribute | Description | +| :-------------- | :----------------------------------------------- | +| `parent` | The DOM node to append to. | +| `resetParent` | If `true`, clears the `parent` before appending. | +| `replaceParent` | If `true`, replaces the `parent` node entirely. | +| `firstOfParent` | Prepend to the `parent` instead of appending. | + +```javascript +// Example: Render a modal directly into the body +h.div( + { + parent: document.body, + class: "modal-overlay", + }, + h.Card("Modal Content"), +); +``` + +#### Component Wrapper (`c_attr` / `$`) + +Pass attributes to a Component's custom element wrapper. + +- **`c_attr`**: An object containing attributes for the wrapper. +- **`$` prefix**: Shorthand for wrapper attributes (e.g., `$class`, `$id`). + +```javascript +// Renders: +h.UserProfile({ + $class: "theme-dark", + $id: "profile-1", +}); +``` --- ## 5. State Management -OpenScript uses `State` objects. When a state's `.value` changes, bound UI updates automatically. +OpenScript uses the `State` class to handle reactive data. When a state's value changes, any dependent components or listeners are automatically notified, triggering UI updates. + +### Creating State -### creating State +You create a state object using the `state` helper function. States can hold primitives(strings, numbers, booleans) or objects. ```javascript import { state } from "modular-openscriptjs"; +// Primitive State const count = state(0); -const user = state({ name: "Guest" }); +const theme = state("dark"); + +// Object State +const user = state({ + id: 1, + name: "Levi", + preferences: { notifications: true }, +}); +``` + +### Using State in Components + +#### 1. Automatic Listening (Render Argument) + +The most common pattern is to pass the state object directly to a component's `render` method. The component automatically subscribes to the state and re-renders whenever its value changes. + +```javascript +export default class CounterDisplay extends Component { + render(countState, ...args) { + // This component automatically re-renders when countState.value changes + return h.div( + h.span("Current Count: "), + h.strong(countState.value), + ...args, + ); + } +} + +// Usage +h.CounterDisplay(count); +``` + +#### 2. Anonymous Components (`v` helper) + +For fine-grained updates without creating a full class component, use the `v` (value) helper. It creates a lightweight anonymous component that listens to the state. This is highly efficient for updating text nodes or attributes. + +```javascript +import { v, app } from "modular-openscriptjs"; +const h = app("h"); + +h.div( + h.h1("Welcome"), + // Only this specific text node updates when 'user' state changes + v(user, (u) => `Hello, ${u.name}!`), +); +``` + +### Reactivity & Objects + +> [!CAUTION] +> **Object Property Pitfall**: Modifying a property of an object stored in state **does NOT** trigger the state to fire. The state system watches the reference of the value, not the deep properties. + +```javascript +// ❌ THIS WILL NOT WORK +user.value.name = "John"; // The UI will not update! + +// ✅ THIS WORKS (Clone & Set) +// You must create a new object reference to trigger the state system. +user.value = { ...user.value, name: "John" }; + +// OR for deep clones/resets +const newUser = JSON.parse(JSON.stringify(user.value)); +newUser.name = "John"; +user.value = newUser; // Triggers update ``` -### Using in Components +**Rule of Thumb**: Treat state values as immutable. Always replace the object entirely when you want to trigger an update. -1. **Auto-Listen**: Pass state to `render()`. - ```javascript - render(count) { return h.div(count.value); } - ``` -2. **Anonymous Component (`v`)**: Update specific parts of the DOM. +### State Helper Methods - ```javascript - import { v } from "modular-openscriptjs"; +The `State` object provides several methods for manual control: - h.div( - "Static content ", - v(count, (c) => `Dynamic: ${c.value}`), - ); - ``` +- **`.value`**: Getter/Setter for the current value. Setting this triggers listeners. +- **`.fire()`**: Manually triggers all listeners without changing the value. Useful if you've mutated an object in place (though not recommended) and need to force a refresh. +- **`.listener(callback)`**: Manually subscribe to changes. + +```javascript +// Manual subscription +count.listener((s) => { + console.log("Count changed to:", s.value); +}); +``` + +### Global vs Local State + +- **Local State**: Defined inside a component's constructor (`this.count = state(0)`). Used for component-specific logic (toggles, form inputs). +- **Global State**: Defined in a shared file (e.g., `contexts.js` or `store.js`) and imported by multiple components. Used for app-wide data (user profile, theme, cart). --- ## 6. Context API -Share state globally across components and mediators. +The Context API provides a mechanism to share state and data across decoupled components and mediators without the need for "prop drilling" (passing data through multiple layers of components). It acts as a shared, central repository for specific domains of your application (e.g., Global, User, Theme). -### Setup +### Setup & Definition + +Defining a context is simple. You register it using `putContext` (usually in a dedicated `contexts.js` file) and then export an accessor for it. ```javascript -// contexts.js +// src/contexts.js import { putContext, context, app } from "modular-openscriptjs"; -// 1. Define +// 1. Register Context Keys +// The first argument is the key used to retrieve it later. +// The second argument is a label (useful for debugging). putContext("global", "GlobalContext"); +putContext("user", "UserContext"); -// 2. Export +// 2. Export Helper Accessors +// This allows other files to simply import 'gc' or 'uc' to access the context. export const gc = context("global"); +export const uc = context("user"); -// 3. Initialize +// 3. Initialize States export function setupContexts() { + // Bulk initialize states for the global context gc.states({ theme: "dark", - currentUser: null, + appName: "OpenScript App", + isLoading: false, }); + // Initialize user context + uc.states({ + profile: null, + isAuthenticated: false, + }); + + // Optional: Register in IoC container for dependency injection app().value("gc", gc); + app().value("uc", uc); } ``` ### Usage +Once defined, you can import and use the context anywhere in your application—in Components, Mediators, or plain JavaScript services. + ```javascript -import { gc } from "./contexts.js"; +import { gc, uc } from "./contexts.js"; + +// Reading State +console.log("Current Theme:", gc.appState.theme.value); -// Read/Write -gc.theme.value = "light"; +// Writing State +// This will trigger updates in any component listening to 'theme' +gc.appState.theme.value = "light"; -// In Component -render() { - return h.div(`Theme is: ${gc.theme.value}`); +// Using in a Component +export default class Header extends Component { + render() { + return h.header( + h.h1(gc.appState.appName.value), + // Bind directly to state for automatic updates + v(uc.appState.isAuthenticated, (auth) => + auth ? h.button("Logout") : h.button("Login"), + ), + ); + } } ``` +### Best Practices + +#### Global vs. Local State + +- **Use Context** for data that needs to be accessed by many completely different parts of your application (e.g., User Session, Theme, Shopping Cart, Notifications). +- **Use Component State** for transient UI data that only matters to that specific component or its immediate children (e.g., whether a modal is open, current input value of a form field). + +#### Performance Warning: Large Lists + +> [!WARNING] +> **Large Datasets**: Do not store massive arrays (e.g., 1000+ items for an infinite scroll) directly in a reactive Context State if they are strictly for display. + +Making a huge array reactive can have performance costs. Instead: + +1. **Mediators** should handle fetching the data. +2. Store the raw data in a non-reactive service or cache. +3. **Components** should retrieve only the slice of data they need to render. +4. Use `replaceParent` or manual DOM appending for infinite lists to avoid re-rendering the entire list on every small update. + --- ## 7. Events & Broker -The **Broker** manages application-wide events. +In a large application, you don't want every part of your code to know about every other part. That's "tight coupling," and it leads to spaghetti code. + +The **Broker** solves this. Think of it like a community bulletin board or a chat room. + +1. **Publisher**: One part of the app (e.g., a "Login Button") posts a message ("User just logged in!"). +2. **Subscriber**: Other parts (e.g., the "Profile Header" or "Analytics Tracker") act on that message. +3. **Decoupling**: The Login Button doesn't know who is listening. It just posts the message and moves on. -### Defining Events (`events.js`) +### 1. Defining Events (`events.js`) -Define events as a "fact" structure. +To prevent typos (like typing `"auth:logni"` instead of `"auth:login"`), we define all our event names in a central file. OpenScript uses a special "fact" object structure. ```javascript +// src/events.js +// We use nested objects set to 'true'. +// OpenScript will convert these into string keys for us. export const appEvents = { auth: { - login: true, // "auth:login" - logout: true, // "auth:logout" + login: true, // Becomes "auth:login" + logout: true, // Becomes "auth:logout" + error: true, // Becomes "auth:error" }, - user: { - updated: true, // "user:updated" + cart: { + added: true, // Becomes "cart:added" + removed: true, // Becomes "cart:removed" + checkout: { + success: true, // Becomes "cart:checkout:success" + }, }, }; ``` -### Registration +### 2. Registration -In `ojs.config.js`: +For the system to understand these events, you must register them in your configuration file. ```javascript +// ojs.config.js +import { appEvents } from "./src/events.js"; + +// Registering validates the structure and enables the system to use them. broker.registerEvents(appEvents); ``` -### Payloads +### 3. Sending Events with Payloads + +When an event happens, you often need to send data with it (e.g., _which_ user logged in?). +OpenScript uses a standardized **Payload** format to keep things organized. A payload has two parts: -Use `payload()` to create standardized event messages. +- **Message**: The actual data (User ID, Cart Item, etc.). +- **Meta**: Extra info (Timestamp, Source, ID). + +Use the `payload` helper to create this package. ```javascript import { payload } from "modular-openscriptjs"; -broker.send(appEvents.auth.login, payload({ userId: 123 }, { source: "ui" })); +// Inside your Login Logic... +const userData = { id: 42, name: "Alice" }; + +// Send the event +// 'this.send' is available in Mediators. +// Anywhere else, you can use broker.send(name, payload) +broker.send(appEvents.auth.login, payload(userData, { timestamp: Date.now() })); +``` + +### 4. Listening & Parsing Payloads + +When you listen for an event (e.g., in a Mediator or Component), you receive the payload as a **JSON string**. You typically need to **parse** it to use the helper methods. + +**Why a string?** It ensures that data remains immutable during transit and can be easily serialized for logging or debugging. + +```javascript +import { EventData } from "modular-openscriptjs"; + +// In a Component or Mediator +async $$auth_login(eventDataString, eventName) { + // 1. Parse the string back into an EventData object + const data = EventData.parse(eventDataString); + + // 2. Access the message + const userId = data.message.get("id"); // 42 + const userName = data.message.get("name"); // "Alice" + + console.log(`User ${userName} logged in!`); +} ``` +#### EventData Helper Methods + +Once parsed, the `data` object gives you safe ways to access info: + +- `data.message.get("key")`: Get a value. +- `data.message.has("key")`: Check if a value exists. +- `data.message.getAll()`: Get the raw object `{ id: 42, name: "Alice" }`. +- `data.meta.get("timestamp")`: Access metadata. + --- ## 8. Mediators -Mediators bridge UI and Logic. They are stateless classes that listen to Broker events. +Mediators are the **"Logic Handlers"** of your application. +In standard frontend frameworks, you might mix your API calls and business logic right inside your UI components. In OpenScript, we separate them. + +**Think of it like a Restaurant:** + +- **Component (Waiter)**: Takes the order (Button Click) and sends it to the kitchen. It doesn't cook. +- **Mediator (Chef)**: Listens for the order, cooks the food (API Call / Logic), and places it on the counter. +- **Broker (Counter)**: The place where orders and food are exchanged. + +### 1. Creating a Mediator + +A Mediator is just a class that extends `Mediator`. It doesn't have a UI. It just listens for events and does work. ```javascript -// AuthMediator.js -import { Mediator } from "modular-openscriptjs"; -import { EventData } from "modular-openscriptjs"; +// src/mediators/AuthMediator.js +import { Mediator, EventData, payload } from "modular-openscriptjs"; export default class AuthMediator extends Mediator { - shouldRegister() { return true; } + // REQUIRED: This tells the framework to scan this class for listeners + shouldRegister() { + return true; + } - // Listen to 'auth:login' - async $$auth_login(eventData, event) { - const data = EventData.parse(eventData); // Parse payload - console.log("User logged in:", data.message); + // Logic: Listen for 'auth' and 'login' events + async $$auth_login(eventDataString, eventName) { + const data = EventData.parse(eventDataString); + const credentials = data.message.getAll(); - this.send("user:updated", payload({ ... })); + try { + // "Cook the food" (Perform Logic) + const user = await fakeApiService.login(credentials); + + // "Serve the food" (Emit Result) + this.send("auth:success", payload({ user })); + } catch (err) { + this.send("auth:error", payload({ error: err.message })); + } } } ``` -### Registration (`boot.js` Pattern) +### 2. Registration (`boot.js` Pattern) + +Just creating a file doesn't make it work. You need to tell OpenScript to "turn on" these mediators. The best way to do this is a dedicated `boot.js` file. + +**Step A: Create `src/boot.js`** +Use the `ojs()` helper to register your mediators. + +```javascript +// src/boot.js +import { ojs } from "modular-openscriptjs"; +import AuthMediator from "./mediators/AuthMediator"; +import CartMediator from "./mediators/CartMediator"; + +export default function bootMediators() { + // This instantiates the mediators and connects their listeners + ojs(AuthMediator, CartMediator); +} +``` + +**Step B: Import in `main.js`** +Call the boot function when your app starts. + +```javascript +// src/main.js +import bootMediators from "./boot"; + +// ... other setup ... + +bootMediators(); // 🚀 Logic layer is now active! +``` + +### 3. Event Listening Tricks + +The `$$` syntax is powerful. You can listen to single events, multiple events, or entire namespaces. + +#### The "OR" Operator (`_`) + +If you put an underscore in the method name, it acts like an "OR". + +```javascript +// Listens for 'user' OR 'login' (Not 'user:login') +$$user_login(data, event) { + console.log(`Triggered by ${event}`); +} +``` + +#### Namespaces (Nested Objects) + +To organize listeners for related events (like `auth:login`, `auth:logout`), use a nested object. + +```javascript +/* + * This property name '$$auth' matches the 'auth' namespace. + * Inside, keys match the sub-events. + */ +$$auth = { + // Listens for 'auth:login' + login: async (data) => { + /* handle login */ + }, + + // Listens for 'auth:logout' + logout: async (data) => { + /* handle logout */ + }, + + // Deep nesting works too: 'auth:password:reset' + password: { + reset: (data) => { + /* ... */ + }, + }, +}; +``` + +### 4. Best Practices -It is best practice to register all mediators in a `boot.js` file. +- **Keep Components Stupid**: Your components should just show data and emit events. Move ALL complex logic to Mediators. +- **Stateless logic**: Mediators generally shouldn't hold a "state". They should act on the payload they receive. If you need to store data, update a Global Context or State. ```javascript // boot.js @@ -679,83 +1162,349 @@ export default function boot() { ## 9. Routing -The Router uses a fluent API. +Single Page Applications (SPAs) don't reload the page when you click a link. Instead, they just swap out the content on the screen. The **Router** handles this job. + +### 1. Basic Setup + +First, let's get the router instance from the container. ```javascript +import { app, dom } from "modular-openscriptjs"; + const router = app("router"); const h = app("h"); -// Basic Route -router.on("/", () => { - h.HomePage({ parent: document.body, resetParent: true }); -}, "home"); +// Define a standardized way to swap content. +// We select a root element and say "Everything inside here belongs to the current route". +const mountPoint = dom.id("app-root"); + +function appRender(component) { + h.App(component, { + parent: mountPoint, + resetParent: route.reset, // Clear previous page + reconcileParent: true, // Smart DOM Diffing (Smoother) + }); +} +``` + +### 2. Defining Routes + +We use `.on(path, callback, name)` to strict define a route. + +- **Path**: The URL pattern. +- **Callback**: What happens when we visit that URL (usually calling our `appRender` function). +- **Name**: A nickname for the route (e.g., 'home'), so we don't have to hardcode URLs later. + +```javascript +// A method chain is the cleanest way +router + .on("/", () => appRender(h.HomePage()), "home") + .on("/about", () => appRender(h.AboutPage()), "about") + .on("/contact", () => appRender(h.ContactPage()), "contact"); +``` + +#### Multiple Paths (`orOn`) + +Sometimes two URLs should go to the same place (e.g., `/login` and `/signin`). + +```javascript +router.orOn(["/login", "/signin"], () => appRender(h.LoginPage())); +``` + +### 3. Route Parameters -// Route with Params -router.on("/user/{id}", () => { - const id = router.params.id; - // render user profile... -}, "user.profile"); +What if we want to show a profile for _any_ user? We use **curly braces** `{}` to make a segment dynamic. -// Groups +```javascript +// Matches /user/1, /user/42, /user/abc +router.on( + "/user/{id}", + () => { + // 1. Get the parameter + const userId = router.params.id; + + // 2. Render component with that ID + appRender(h.UserProfile({ id: userId })); + }, + "user.profile", +); +``` + +### 4. Grouping Routes (`prefix`) + +If you have an Admin section, you don't want to type `/admin/dashboard`, `/admin/users`, etc., over and over. + +```javascript router.prefix("/admin").group(() => { - router.on("/dashboard", ...); - router.on("/settings", ...); + // URL: /admin/dashboard + router.on("/dashboard", () => appRender(h.Dashboard()), "admin.dash"); + + // URL: /admin/settings + router.on("/settings", () => appRender(h.Settings()), "admin.settings"); }); +``` + +### 5. Navigation & Logic -// Navigation -router.to("user.profile", { id: 42 }); +Instead of ``, we use the router to navigate programmatically. -// Default/404 -router.default(() => router.to("home")); +```javascript +// Go to a URL +router.to("/about"); + +// Go to a Named Route (Better practice!) +// This generates the URL for you. If you change the URL structure later, this code doesn't break. +router.to("user.profile", { id: 42 }); // Goes to /user/42 + +// Check where we are (Useful for highlighting menu items) +if (router.is("home")) { + console.log("We are home!"); +} +``` + +### 6. The 404 Page (Default) + +If the user types a garbage URL, show them a nice error page. + +```javascript +router.default(() => { + appRender(h.NotFoundPage()); +}); ``` --- ## 10. IoC Container -Manage dependencies via `app()`. +As your app grows, managing connections between everything (Routers, APIs, Settings) becomes messy. +The **IoC (Inversion of Control) Container** solves this by acting as a "central warehouse" for all your services. + +Instead of writing `new ApiService()` everywhere, you simply ask the container: _"Hey, give me the API Service"_ and it hands it to you. -### Accessing Services +### 1. The `app()` Helper + +The `app()` function is your key to the warehouse. ```javascript +import { app } from "modular-openscriptjs"; + +// 1. Get a Service const router = app("router"); +const broker = app("broker"); + +// 2. Get the Container itself (to register things) const container = app(); ``` -### Registering Services +### 2. Registering Services + +You typically do this in `ojs.config.js` or a `boot.js` file. + +#### A. Values (Config/Constants) + +Great for API keys or simple objects. ```javascript -// Singleton (Single instance) -app().singleton("api", ApiService); +app().value("config", { + apiKey: "xyz-123", + theme: "dark", +}); +``` -// Transient (New instance every time) -app().transient("logger", Logger); +#### B. Singletons (One Instance Forever) -// Value (Constant/Instance) -app().value("config", { apiUrl: "..." }); +The container creates the object **once** (the first time you ask for it) and then reuses it. Perfect for stateful services like a `Router` or `AuthService`. + +```javascript +import AuthService from "./services/AuthService"; + +// Register +app().singleton("auth", AuthService); + +// Usage +const auth1 = app("auth"); // Creates new instance +const auth2 = app("auth"); // Returns SAME instance ``` -### Dependency Injection +#### C. Transients (New Instance Every Time) + +The container creates a **fresh** object every time you ask. Good for things like loggers or HTTP requests. + +```javascript +app().transient("logger", Logger); +``` + +### 3. Dependency Injection (Magic!) + +Here is the superpower. If your `UserService` needs the `AuthService` and `Broker` to work, you don't have to pass them manually. The container does it for you. ```javascript class UserService { - constructor(api) { - this.api = api; + // The container will pass these arguments to the constructor + constructor(auth, broker) { + this.auth = auth; + this.broker = broker; + } + + deleteAccount() { + this.auth.currentUser.delete(); + this.broker.send("user:deleted"); } } -// Inject 'api' service into UserService -app().singleton("userService", UserService, ["api"]); +// Registering: Define the array of dependency names ["auth", "broker"] +app().singleton("user", UserService, ["auth", "broker"]); + +// Usage: Just ask for 'user', and the rest is automatic! +const userService = app("user"); ``` +### 4. Core Services + +OpenScript comes with these built-in services ready to use: + +| Service Name | Description | +| :------------------ | :----------------------------- | +| `"h"` | The Markup Engine (HTML Proxy) | +| `"router"` | The Navigation Router | +| `"broker"` | The Event Broker | +| `"contextProvider"` | Global Context Manager | +| `"repository"` | Internal Component Repository | + --- ## 11. Helper Functions -Global utilities available in `window` or via import. +OpenScript provides a suite of global utility functions to make your life easier. + +### Logic Helpers + +These are available globally (like `console` or `Math`). + +#### 1. `ifElse(condition, trueValue, falseValue)` + +A smarter ternary operator. If you pass **functions** as the values, they are only executed if chosen (lazy evaluation). + +```javascript +// Simple +const status = ifElse(isOnline, "Online", "Offline"); + +// Lazy (Function is only called if isValid is true) +const result = ifElse(isValid, () => heavyCalculation(), "Invalid"); +``` + +#### 2. `coalesce(value1, value2)` + +Returns the first value that isn't `null` or `undefined`. Great for defaults. + +```javascript +const displayName = coalesce(user.nickname, user.name, "Guest"); +``` + +#### 3. `each(list, callback)` + +Safely iterate over **Arrays** OR **Objects**. + +```javascript +// Array +each([1, 2, 3], (val) => console.log(val)); + +// Object +each({ a: 1, b: 2 }, (val, key) => console.log(`${key}: ${val}`)); +``` + +### DOM Utilities (`dom`) + +Forget `document.querySelector` and friends. Use `dom`. + +- **`dom.id("my-id")`**: Shortcut for `getElementById`. +- **`dom.get(".class")`**: Shortcut for `querySelector`. +- **`dom.all("div")`**: Shortcut for `querySelectorAll`. +- **`dom.put("Hi", el)`**: Sets innerHTML (safely). + +### Framework Helpers + +- **`app(name)`**: Access services from the IoC container. +- **`component(uid)`**: Find a live component instance by its ID. +- **`state(val)`**: Create a new state. +- **`context(name)`**: Access a global context. +- **`v(state, cb)`**: Create an anonymous reactive text node. + +--- + +## 12. Tailwind Integration + +OpenScript works seamlessly with TailwindCSS. The JIT engine automatically scans your JS files for class names. + +### 1. How it Works + +Tailwind looks for strings in your code that match class names. +Because OpenScript uses standard `class: "..."` attributes, it Just Works™. + +```javascript +// Tailwind sees this string and generates the CSS! +h.div({ class: "bg-blue-500 text-white p-4 rounded" }, "Hello!"); +``` + +### 2. Dynamic Classes (The "Safelist" Trap) + +Tailwind analyzes your code **statically** (it reads text, it doesn't run code). +This means you **CANNOT** construct class names dynamically if the full string doesn't exist in your code. + +```javascript +// ❌ WRONG: Tailwind won't see "bg-red-500" +const color = "red"; +h.div({ class: `bg-${color}-500` }); + +// ✅ CORRECT: Full strings +const classes = isError ? "bg-red-500" : "bg-blue-500"; +h.div({ class: classes }); +``` + +**Solution:** If you MUST build dynamic strings, you need to add the patterns to the `safelist` in your `tailwind.config.js`. + +```javascript +// tailwind.config.js +module.exports = { + safelist: [ + { pattern: /bg-(red|green|blue)-(100|500)/ }, // Forces these to ALWAYS be included + ], +}; +``` + +### 3. Best Practices + +#### Helper Functions + +For conditional classes, just like React/Vue, use a helper or template literals. + +```javascript +// Cleaner than ternary soup +function classNames(...classes) { + return classes.filter(Boolean).join(" "); +} + +h.button({ + class: classNames( + "px-4 py-2 rounded", // Always applied + isActive && "bg-blue-500", // Only if active + isDisabled && "opacity-50", // Only if disabled + ), +}); +``` + +#### Custom Styles (`@apply`) + +If a class string gets too long, extract it to CSS using `@apply`. + +```css +/* src/style.css */ +.btn-primary { + @apply px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600; +} +``` -- **`ifElse(condition, trueVal, falseVal)`**: Logic helper. -- **`coalesce(v1, v2)`**: Null coalescing. -- **`each(list, cb)`**: Safe iteration. -- **`dom.id(id)` / `dom.get(sel)`**: DOM query shortcuts. -- **`component(id)`**: Get component instance by ID. +````javascript +h.button({ class: "btn-primary" }, "Click Me"); +```- **`v(state, cb)`**: Create an anonymous reactive text node. +```` diff --git a/docs/2-components.md b/docs/2-components.md index d7db224..894595f 100644 --- a/docs/2-components.md +++ b/docs/2-components.md @@ -60,11 +60,12 @@ import { app } from "modular-openscriptjs"; const h = app("h"); -export default function MyFunctionalComponent(props = {}) { +export default function MyFunctionalComponent(title, content, ...args) { return h.div( { class: "card" }, - h.h2(props.title || "Default Title"), - h.p(props.content), + h.h2(title || "Default Title"), + h.p(content), + ...args, ); } ``` @@ -178,23 +179,7 @@ export default class UserProfile extends Component { async $$auth_logout(eventData, event) { // 1. Parse Data const data = EventData.parse(eventData); - - // 2. Get Safe Component Instance (if needed) - // Note: Broker listeners in components might not automatically receive componentId - // depending on binding. If 'this' is unsafe, ensure you have a reference. - // However, usually 'this' in Component methods is bound. - // BUT if the user explicitly warned about 'this' in listeners generally: - console.log(`Received ${event}`); - this.cleanUp(); // 'this' is usually safe in class classes unless stated otherwise, - // but following the pattern: if it's an auto-attached listener, - // verify if it receives componentId? - // The user said: "In those mounted function... use component(id)". - // Mounted functions usually refer to $_. - // Let's assume standard methods $$ might still bind 'this' or - // we should stick to the safe pattern if applicable. - // For now, I will assume $$ methods on Component might still work with 'this', - // but I will respect the standard signature (eventData, event). } } ``` diff --git a/docs/9-mediators.md b/docs/9-mediators.md index 4602adb..190e27d 100644 --- a/docs/9-mediators.md +++ b/docs/9-mediators.md @@ -120,6 +120,7 @@ Mediators can send events using `this.send(event, payload(...))` or `this.broadc ```javascript import { payload } from "modular-openscriptjs"; +// listen to auth and login events async $$auth_login(eventData, event) { // Validate... this.send( From 9118c20b548654bb9b5fd1672b0ea03cfac3dd3f Mon Sep 17 00:00:00 2001 From: levizwannah Date: Sat, 24 Jan 2026 00:59:49 +0300 Subject: [PATCH 46/46] finished documentation --- README.md | 162 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 139 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 079b0a4..e58b1b6 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,52 @@ -# Modular OpenScript Framework +# OpenScriptJs -A modern, modular, event-driven JavaScript framework built for scalability and maintainability. OpenScript combines the power of **Inversion of Control (IoC)**, **Reactive State Management**, and a **Component-Based Architecture** into a lightweight package with zero runtime dependencies. +

+ + + OpenScriptJs Logo + +

-## 🚀 Key Features +

+ The Progressive, PHP-Inspired JavaScript Framework for Artisans. +

-- **IoC Container**: Centralized dependency management using a robust container and `app()` helper. -- **Reactive State**: Proxy-based state management with automatic UI updates using `state()`. -- **Event-Driven**: Powerful `Broker` and `Mediator` pattern for decoupled communication. -- **Component-Based**: Class-based components with lifecycle hooks and functional stateless components. -- **OpenScript Markup (OSM)**: A powerful DSL for generating HTML without a Virtual DOM overhead. -- **Fluent Router**: Expressive, fluent API for client-side routing with nested routes and grouping. -- **Context API**: Share state globally without prop drilling. -- **Lightweight**: Zero runtime dependencies, pure JavaScript. -- **Vite Integration**: Optimized build process with automatic component discovery. +

+ NPM Version + License + Issues +

+ +## Introduction + +OpenScriptJs is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable, creative experience to be truly fulfilling. OpenScript attempts to take the pain out of development by easing common tasks used in the majority of web projects, such as simple routing, powerful state management, and decoupled event handling. + +It combines the best concepts from sophisticated backend architectures—like **Inversion of Control (IoC)** and **Mediator Patterns**—with the modern reactivity of frontend development. The result is a lightweight, zero-dependency framework that scales from small widgets to complex Single Page Applications without the bloat. + +## 🚀 Why OpenScriptJs? + +We didn't just build another framework; we built a toolset for developers who value structure and clarity. + +- **IoC Container**: + _Why?_ Managing dependencies manually is messy. Our robust container and `app()` helper give you a centralized way to manage your services, promoting loose coupling and testability. + +- **Reactive State**: + _Why?_ UI should be a function of state. Our proxy-based `state()` system automatically updates your DOM when data changes, without the complexity of a Virtual DOM. + +- **Event-Driven Architecture**: + _Why?_ Components shouldn't talk directly to each other; it leads to spaghetti code. Our powerful `Broker` and `Mediator` pattern enables true decoupling. + +- **Component-Based**: + _Why?_ Reusability is key. Build encapsulated functional or class-based components with full lifecycle hooks. + +- **OpenScript Markup (OSM)**: + _Why?_ Context switching between HTML and JS breaks flow. OSM allows you to generate HTML using expressive JavaScript, giving you the full power of the language right in your views. + +- **Fluent Router & Context API**: + _Why?_ Modern apps need robust navigation and global state sharing without "prop drilling". We provide both out of the box. + +- **Zero Dependencies**: + _Why?_ Bloat slows you down. OpenScriptJs is pure, lightweight JavaScript. --- @@ -468,6 +502,14 @@ export default function Card(title, content, ...args) { } ``` +### 💡 Choosing the Right Component Type + +- **Use Functional Components** when your component is just receiving data and displaying it. They are lighter, faster, and easier to test. +- **Use Class Components** when you need: + - Internal state (toggle buttons, form inputs). + - Lifecycle hooks (`$_mounted` for API calls or setting up 3rd party libs). + - Complex event handlers. + ### Naming Conventions - **Classes/Functions**: PascalCase (e.g., `UserProfile`). @@ -551,6 +593,16 @@ h.button({ onclick: this.method("handleClick") }, "Click"); OpenScript Markup (OSM) is a powerful, JavaScript-based Domain Specific Language (DSL) for generating HTML. At its core is the `h` proxy service, which translates property accessors into DOM elements. +### 💡 Why OSM? + +You might ask, "Why not just use HTML or JSX?" + +While JSX is popular, it requires a build step. **OSM is pure JavaScript.** + +- **No Compilation Required**: It works directly in the browser. +- **Full Power of JS**: You can use `map`, `filter`, variables, and functions directly within your structure without context switching. +- **Composition**: Functions can return arrays of elements, making composition trivial. + ### Basic Usage You access OSM via the `h` service from the IoC container. @@ -732,6 +784,13 @@ h.UserProfile({ OpenScript uses the `State` class to handle reactive data. When a state's value changes, any dependent components or listeners are automatically notified, triggering UI updates. +### ⚡ The Magic of Proxies + +OpenScript leverages modern JavaScript **Proxies**. This means you don't need special setter functions like `setState({ count: 1 })` found in other frameworks. You simply assign the value, and the framework handles the rest. + +- **Clean Syntax**: `count.value = 5`. That's it. +- **Micro-Updates**: Only the specific nodes bound to that state update in the DOM. The entire component doesn't necessarily re-render, making it incredibly performant. + ### Creating State You create a state object using the `state` helper function. States can hold primitives(strings, numbers, booleans) or objects. @@ -833,7 +892,18 @@ count.listener((s) => { ## 6. Context API -The Context API provides a mechanism to share state and data across decoupled components and mediators without the need for "prop drilling" (passing data through multiple layers of components). It acts as a shared, central repository for specific domains of your application (e.g., Global, User, Theme). +The Context API provides a mechanism to share state and data across decoupled components and mediators without the need for "prop drilling" (passing data through multiple layers of components). It acts as a shared, central repository for specific domains of your application. + +### 💼 Common Use Cases + +Use Context for data that is truly global: + +- **User Session**: Is the user logged in? Who are they? +- **Theme Settings**: Dark mode vs Light mode. +- **Language/Localization**: Current active language. +- **Shopping Cart**: Items currently in the cart. + +For everything else (form inputs, toggle states), stick to local Component State to keep your app simple. ### Setup & Definition @@ -1028,13 +1098,21 @@ Once parsed, the `data` object gives you safe ways to access info: ## 8. Mediators Mediators are the **"Logic Handlers"** of your application. -In standard frontend frameworks, you might mix your API calls and business logic right inside your UI components. In OpenScript, we separate them. -**Think of it like a Restaurant:** +### 🧠 Philosophy: Separation of Concerns -- **Component (Waiter)**: Takes the order (Button Click) and sends it to the kitchen. It doesn't cook. -- **Mediator (Chef)**: Listens for the order, cooks the food (API Call / Logic), and places it on the counter. -- **Broker (Counter)**: The place where orders and food are exchanged. +In many frameworks, business logic often bleeds into UI components, making them hard to read and impossible to test. OpenScript enforces a strict separation: + +- **Components**: Responsible ONLY for rendering and user interaction. +- **Mediators**: Responsible for API calls, data manipulation, and business rules. + +#### The Restaurant Analogy + +Think of your application like a busy restaurant: + +- **Component (Waiter)**: Takes the order (Button Click) and sends it to the kitchen. It doesn't cook anything; it just shouting "Order Up!". +- **Mediator (Chef)**: Listens for the order, cooks the food (API Call), and rings the bell when done. +- **Broker (Counter)**: The central communication hub where orders are placed and picked up. ### 1. Creating a Mediator @@ -1276,8 +1354,12 @@ router.default(() => { ## 10. IoC Container -As your app grows, managing connections between everything (Routers, APIs, Settings) becomes messy. -The **IoC (Inversion of Control) Container** solves this by acting as a "central warehouse" for all your services. +As your app grows, managing connections between everything (Routers, APIs, Settings) becomes messy. The **IoC (Inversion of Control) Container** solves this by acting as a "central warehouse" for all your services. + +### 🏭 Why Inversion of Control? + +Directly importing dependencies (e.g., `import api from './api'`) creates rigid, hard-to-test code. +The IoC container allows you to swap implementations easily. This is excellent for testing: you can inject a "Fake API" when running unit tests without changing a single line of your component code. Instead of writing `new ApiService()` everywhere, you simply ask the container: _"Hey, give me the API Service"_ and it hands it to you. @@ -1504,7 +1586,41 @@ If a class string gets too long, extract it to CSS using `@apply`. } ``` -````javascript +```javascript h.button({ class: "btn-primary" }, "Click Me"); -```- **`v(state, cb)`**: Create an anonymous reactive text node. -```` +``` + +--- + +## 🤝 Contributing + +Thank you for considering contributing to the OpenScriptJs framework! The contribution guide works as follows: + +1. Fork the repository. +2. Create your feature branch (`git checkout -b feature/AmazingFeature`). +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`). +4. Push to the branch (`git push origin feature/AmazingFeature`). +5. Open a Pull Request. + +If you discover a security vulnerability within OpenScriptJs, please send an e-mail to Levi Kamara Zwannah via [levizwannah@gmail.com](mailto:levizwannah@gmail.com). All security vulnerabilities will be promptly addressed. + +## 🐛 Reporting Bugs + +If you encounter any bugs or issues, please report them using the [GitHub Issue Tracker](https://github.com/OpenScriptJs/modular-openscript/issues). Please include: + +- A detailed description of the bug. +- Steps to reproduce the behavior. +- Expected vs. actual results. +- Screenshots or code snippets if applicable. + +## 📜 License + +The OpenScriptJs framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). + +## ✍️ Author + +OpenScriptJs is a product of **Levi Kamara Zwannah**. + +--- + +_Built with ❤️ for developers who love code._