Skip to content
uidb-devPublic

About

No-bundler, OOP-first SPA framework with Android-style views

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

16 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

JOPS — JavaScript OOP SPA Framework

A no-bundler, vanilla-JavaScript SPA framework for developers who think in OOP and native-mobile patterns.

What is JOPS?

JOPS is a lightweight SPA framework built on native ES6 modules. It discards the complexity of modern build toolchains while keeping a structured, class-based architecture inspired by Android's view and lifecycle model.

No Webpack. No Babel. No compiler. Just <script type="module"> and a browser.

Where JOPS Fits

No Bundler OOP Classes Declarative View Nesting No Element Registration
JOPS ✅ ✅ ✅ ✅
Lit ✅ ✅ ⚠️ Web Components only ❌
Stimulus ✅ ✅ ❌ ✅
Alpine.js ✅ ❌ ❌ ✅
Angular ❌ ✅ ✅ ✅
Svelte ❌ (compiler) ✅ ✅ ✅

Target audience: Teams building internal tools, dashboards, embedded WebViews, or native mobile hybrid apps who want a structured, class-based frontend architecture without the npm/webpack/babel ecosystem overhead.


JOPS Demos

Demo Description
JOPS Hello World A minimal starter app showing views, layouts, routing, and event binding

Installation

CDN

The fastest way to get started. Add these two tags to any HTML page:

<script type="importmap">
{
  "imports": {
    "jops-core": "https://cdn.jsdelivr.net/npm/jops-core@latest/lib/jops-core.min.js"
  }
}
</script>
<script type="module" src="https://cdn.jsdelivr.net/npm/jops-core@latest/lib/jops-core.min.js" data-app="./src/App.js"></script>

The import map lets your app modules use import { View } from "jops-core". The module script tag bootstraps the app entry point specified in data-app.

App.js is your entry point — a file you provide that instantiates your root View and mounts it to the page. Point data-app to wherever you place it.

Replace @latest with a specific version (e.g. @1.0.4) to pin. No build step, no npm, no bundler.

Note: ES modules require an HTTP server — open your project with npx serve (or any static server) rather than double-clicking index.html. Browsers block import on file:// URLs.

For editor autocomplete and inline docs without installing the full package, add jops-core as a dev dependency:

npm i -D jops-core

Your editor picks up lib/jops-core.d.ts automatically via the "types" field in package.json.

npm

For greenfield projects that want local tooling and scaffolding:

npm init -y
npm install jops-core
npx jops-core init

npx jops-core init vendors the library to /lib/jops-core.min.js, configures index.html with the import map, and injects build and postinstall scripts into package.json.

Updating the library

When a new version of JOPS is released, run both commands:

npm i jops-core@latest
npx jops-core update

npm i updates node_modules; npx jops-core update copies the new lib/jops-core.min.js into your project so the browser picks it up. Running update without npm i first has no effect — it copies whatever version is already in node_modules.

Build for production

npm run build

Generates a clean dist/ folder ready to serve.

VS Code — HTML highlighting in layout templates

Install the lit-html extension, then add this to your .vscode/settings.json:

{
  "lit-html.tags": ["layout"],
  "editor.tokenColorCustomizations": {
    "textMateRules": [
      {
        "scope": "invalid.illegal.unrecognized-tag.html",
        "settings": { "foreground": "#569CD6" }
      }
    ]
  }
}

This gives you HTML syntax highlighting, Emmet, and autocomplete inside every layout`...` tagged template. Custom view tags (e.g. <MyView>) are coloured blue like standard HTML elements instead of red. npx jops-core init configures this automatically.


Hybrid Mobile Apps (Cordova, Ionic & Capacitor)

JOPS runs unchanged inside a mobile WebView, so the same app can ship to the iOS App Store and Google Play through a hybrid shell. Nothing on this page affects JOPS in a regular browser: every mobile adjustment is either feature-detected (it only runs when the native shell is present) or a CSS value that resolves to 0 outside it.

Shell Status
Apache Cordova (iOS + Android) Supported and tested (cordova-ios 8, cordova-android 15)
Capacitor (iOS + Android) Supported and tested (Capacitor 8) — install @capacitor/app for the Android back button
Ionic Use Ionic's framework-free UI components (@ionic/core) on top of Cordova or Capacitor. ionic start always scaffolds an Angular, React or Vue app, which JOPS is not meant to be combined with — start from a plain Cordova or Capacitor project instead

Setup order

1. Create the hybrid project and its platforms.

# Cordova
cordova create my-app com.example.myapp MyApp
cd my-app
cordova platform add ios android
# Capacitor
mkdir my-app && cd my-app
npm init -y
npm i @capacitor/core @capacitor/ios @capacitor/android @capacitor/app
npm i -D @capacitor/cli
npx cap init MyApp com.example.myapp --web-dir www
npx cap add ios
npx cap add android

2. Install JOPS in the project root — the folder with package.json, not inside www/. Cordova and Capacitor copy the whole web folder into the app, so a node_modules/ there would be bundled too.

npm i jops-core

3. Run init from the project root (running it from inside www/ / your webDir works too):

npx jops-core init

init detects the project type and writes the app into the web folder that the shell actually serves — www/ for Cordova, the webDir from capacitor.config.* for Capacitor:

[jops-core] Detected cordova project → web folder: www/

4. Build and run with the shell's own tools: cordova run ios / cordova run android, or npx cap sync followed by npx cap run ios / npx cap run android (or open the native project in Xcode / Android Studio).

What npx jops-core init configures

Web Cordova Capacitor
index.html, lib/, template current folder www/ webDir
.vscode/ and package.json scripts current folder project root project root
viewport-fit=cover — ✓ ✓
Content-Security-Policy — adds 'unsafe-inline' (inline import map) and blob: (Thread workers) to script-src — (Capacitor has none by default)
<script src="cordova.js"> — ✓ —
iOS custom scheme in config.xml — ✓ (app://localhost) — (Capacitor uses capacitor://localhost)
"build" script in package.json ✓ — (use cordova build) — (use npx cap sync)
"postinstall": "npx jops-core init" ✓ ✓ ✓
  • Every step checks first, so running init again — including through postinstall — never duplicates anything.

  • Cordova's sample page: when you pick the Empty or Hello World template and www/index.html is still the page created by cordova create, init renames it to www/index.cordova.html and writes a JOPS index.html instead (the sample's js/index.js expects markup that a JOPS app replaces). No files are deleted; css/, img/ and js/ from the sample can be removed by hand.

  • iOS scheme (Cordova): JOPS loads App.js, views and layouts with native import() and fetch(), which WebViews block on file:// pages. Cordova Android and Capacitor already serve from https://localhost; for Cordova iOS, init adds the following to config.xml (applied on the next cordova prepare / build / run):

    <platform name="ios">
        <preference name="scheme" value="app" />
        <preference name="hostname" value="localhost" />
    </platform>

    Values you already set are never changed — any custom scheme works. init warns if the iOS scheme is file, http or https, or if AndroidInsecureFileModeEnabled is true, since both keep the app on file://. Already-published apps: the new origin cannot see data stored under file:// (localStorage, IndexedDB), so plan a migration if your app stores data locally.

  • npx jops-core update writes to the same web folder, and npx jops-core build is skipped in hybrid projects (the web folder is shipped as-is).

  • Override the detection with --cordova, --capacitor or --web (for example npx jops-core init --web to treat the current folder as a plain web app).

For reference, the index.html that init produces for a new Cordova project:

<head>
  <meta charset="UTF-8" />
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self' data: blob: https://ssl.gstatic.com; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' data: blob:; media-src * blob:; img-src 'self' data: blob: content:;" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
  <script type="importmap">
    { "imports": { "jops-core": "/lib/jops-core.min.js" } }
  </script>
</head>
<body>
  <script src="cordova.js"></script>
  <script type="module" src="/lib/jops-core.min.js" data-app="./src/App.js"></script>
</body>

Android back button

In Cordova and Capacitor, JOPS connects the Android back button (key and gesture) to the Router automatically:

  • If Router.singleton().canGoBack() is true, it calls Router.back() — the current view receives onBackPressed() exactly as with browser back.
  • On the first screen, it closes the app (navigator.app.exitApp()), the standard Android behaviour.

This is required on Android 16 (targetSdk 36, predictive back): without a backbutton listener, Cordova lets the system handle back and the app closes on the first press regardless of history. It also smooths over a cordova-android 15 issue where one key press fires backbutton twice.

The hook only activates on the deviceready event, which Cordova and Capacitor fire and a regular browser never does. If your app handles backbutton itself, turn the built-in handler off:

<script type="module" src="/lib/jops-core.min.js" data-app="./src/App.js" data-native-back="off"></script>

Capacitor: install the official App plugin — no code is needed:

npm i @capacitor/app
npx cap sync

Capacitor's Cordova compatibility layer fires deviceready and forwards JOPS's backbutton listener to the plugin, so the behaviour above applies unchanged. Without @capacitor/app, Capacitor does not intercept back at all and the app closes on the first press; JOPS logs a console warning in that case.

Do not also register App.addListener("backButton", ...) while the built-in handler is on: the plugin calls your listener and fires backbutton, so every press would go back twice.

To handle back yourself in Capacitor, opt out with data-native-back="off" and use the plugin's global (no bundler needed):

const { App } = window.Capacitor.Plugins;

App.addListener("backButton", () => {
  const router = Router.singleton();
  if (router?.canGoBack()) router.back();
  else App.exitApp();
});

Notches, Dynamic Island and the home indicator

With viewport-fit=cover (added by npx jops-core init in Cordova and Capacitor projects) the page extends under the status bar and the home indicator. Pad fixed or sticky bars with the safe-area insets — they are 0 in a regular browser, so the layout is unchanged there:

.header {
  padding-top: calc(1rem + env(safe-area-inset-top, 0px));
}
.footer {
  padding-bottom: calc(2rem + env(safe-area-inset-bottom, 0px));
}

The Hello World template already does this.

Debugging

  • Android: open chrome://inspect in desktop Chrome while the emulator or a device runs the debug build.
  • iOS: Safari → Develop → Simulator → your app.

A cordova_plugins.js "not found" error in the console is expected when the project has no Cordova plugins installed; it is harmless.


Class Reference

View — Base Class

The foundational building block. Every screen, component, and sub-component extends View.

import { View } from "jops-core";

export default class MyView extends View { ... }

Lifecycle (called by the framework in order)

Method When called
async loadLayout(path?) Override to load a layout .js module or .html file
inflate() Converts this.layout string into this.domNode
async onLayout() DOM is live — add subviews, read initial data
async onResume() View becomes visible — called on first display and on every return navigation
bindEvents() Wires on* attributes on [jops-event-bind] elements to class methods
onBackPressed() Called by Router when the user navigates back from this view (browser back, Router.back(), Android back button in hybrid apps) — never on forward navigation

Instance Methods

Method Description
get(id) Returns an Element wrapper for the child with the given DOM id
setStyle(style) Injects a .css file link or inline style tag into <head>, deduped
addSubView(containerId, view) Appends view into the child element with id="containerId", runs its full lifecycle
insertSubView(view, index) Inserts view at position index within this.domNode, runs its full lifecycle
removeSubView(view) Removes view from DOM and this.subViews, calls view.destroy()
find(condition) Depth-first search across full subview subtree; returns flat array of all views matching the predicate
show() Restores this.domNode display (removes inline display override)
hide() Sets this.domNode to display: none
destroy() Recursively destroys subtree — calls destroy() on all subviews, removes domNode from DOM, nulls references

Declarative Event Binding

Mark any element with jops-event-bind and add on* attributes whose values are method names on the View class:

<button jops-event-bind onclick="onSave">Save</button>
onSave(event) { ... }

Element — DOM Accessor (returned by View.get())

Returned by view.get(id). Wraps a single DOM element for reading and writing.

Method Description
set(value) Sets innerHTML of the element — accepts plain text or HTML strings
value() Returns the current innerHTML of the element
this.get("username").set("Alice");
const current = this.get("username").value();

layout — Tagged Template Function

XSS-safe tagged template literal for defining HTML layouts. Dynamic values are HTML-escaped automatically. Use raw() to opt out for trusted HTML.

import { layout, raw } from "jops-core";

const html = layout`<p>${userInput}</p>`;             // escaped
const html = layout`<p>${raw("<b>trusted</b>")}</p>`; // raw HTML

HTML file layouts

In addition to .js layout modules, loadLayout() accepts a path to a plain .html file. The framework fetches the file, caches it for the lifetime of the page, and assigns its contents as the view's layout — no import, no export, no tagged template required.

Via layout attribute (declarative):

<View jops layout="/src/layout/mainlayout.html"></View>

Via loadLayout() (programmatic):

await root.loadLayout("/src/layout/mainlayout.html");
root.render();

mainlayout.html is a plain HTML fragment:

<div class="jops-root">
  <View jops layout="/src/layout/layout_header.html"></View>
  <main>
    <Router jops animation="slide">
      <View path="/home" jops layout="/src/layout/layout_home.html"></View>
      <MyView path="/detail" jops src="/src/MyView.js"></MyView>
    </Router>
  </main>
</div>

Note: .html layouts are fetched at runtime and cached in memory — identical dedup behaviour to the browser's module cache for .js layouts. Each unique path is fetched once per page load.


raw(html) — Function

Wraps a trusted HTML string to bypass escaping inside a layout template.

import { raw } from "jops-core";

raw("<b>Hello</b>"); // passed through as-is inside layout``

Router — extends View

Hash-based SPA router. Singleton. Declared in the layout as <Router jops animation="slide">.

Two Navigation Paradigms

Pattern Mechanism Instance Android analogy
href="#path" flat, hashchange pre-existing, reused Tab navigation
navigate(path, Class?) hierarchical stack always new instance startActivity()
replace(path, Class?) resets stack existing or new startActivity(FLAG_CLEAR_TOP)

Static Methods

Method Description
Router.singleton() Returns the single Router instance

Instance Methods

Method Description
navigate(path, ClassDef?) Creates a new view instance for path, pushes onto the nav stack, animates forward. Updates address bar to #path?params. ClassDef required for dynamic routes not in the static route map
back() Navigates back — pops the nav stack with animation if non-empty, otherwise calls history.back()
canGoBack() Returns true if there is an earlier JOPS history entry to go back to (like checking the back stack in Android)
replace(path, ClassDef?) Clears the nav stack and navigates to path. Uses existing flat route instance if available, otherwise creates a new one from ClassDef
getParams() Returns the current URL query params as a plain object. Works for both static (href="#path?k=v") and dynamic (navigate("/path?k=v")) routes

Animation

Set via the animation attribute on the <Router> element:

Value Behaviour
"none" (default) Instant switch, no animation
"slide" Forward: incoming slides in from right. Back: outgoing slides out to right
"fade" Crossfade between outgoing and incoming

Animation only occurs on navigate() (forward) and back() when the nav stack is non-empty. Flat tab switching is always instant.

Layout Declaration

<Router jops animation="slide">
  <view jops src="/src/Home.js" path="/home"></view>
  <view jops src="/src/Form.js" path="/form"></view>
</Router>

Note: The legacy type="jops" and type="jops-event-bind" syntax is still supported for backwards compatibility.

Query Params

// static route with params
<a href="#form?tab=profile">Profile</a>;

// dynamic navigation with params
Router.singleton().navigate("/detail?id=42&mode=edit", DetailView);

// read params in any view's onResume()
const { id, mode } = Router.singleton().getParams();

Store — Singleton State Container

App-wide runtime state. No reactivity — a plain shared key-value store with dot-notation path access. Views read from Store in onResume() and write in event handlers.

import { Store } from "jops-core";

const store = Store.singleton();
Method Description
Store.singleton() Returns the single Store instance, creating it on first call
get(path) Reads a value by dot-notation path, e.g. "user.name"
set(path, value) Writes a value by dot-notation path; auto-creates intermediate objects
clear() Resets the entire state to {}
Store.singleton().set("user.name", "Alice");
Store.singleton().get("user.name"); // "Alice"
Store.singleton().set("cart.items", [1, 2, 3]);
Store.singleton().clear();

EventBus — Static Pub/Sub

Stateless cross-view event broadcasting. All methods are static — no instance needed.

Method Description
EventBus.subscribe(event, callback) Registers a listener for the named event
EventBus.send(event, data?) Fires the named event to all subscribers with an optional data payload
EventBus.unsubscribe(event, callback) Removes a specific listener by reference
import { EventBus } from "jops-core";

EventBus.subscribe("user:login", (data) => console.log(data.name));
EventBus.send("user:login", { name: "Alice" });
EventBus.unsubscribe("user:login", handler);

Thread — Web Worker Base Class

Base class for running CPU-bound work on a background thread. Extend and override run() and onMessage().

Method Description
start(params?) Spawns a Web Worker, runs run(params) on it, and wires onMessage for results
run(params) Override to define the work. Runs inside the Worker — self-contained, no outer scope access
onMessage(event) Override to handle results posted back from the worker via self.postMessage()
terminate() Terminates the worker immediately and clears the internal reference
import { Thread } from "jops-core";

class HashThread extends Thread {
  run(params) {
    // runs on a background thread — no access to the main thread or class instance
    let hash = 0;
    for (let i = 0; i < params.data.length; i++) hash ^= params.data.charCodeAt(i);
    self.postMessage(hash);
  }
  onMessage(event) {
    console.log("Hash result:", event.data);
  }
}

const t = new HashThread();
t.start({ data: "hello world" });
// later:
t.terminate();

Note: Each Thread instance spawns one OS thread. Avoid running more concurrent threads than the device has logical CPU cores — excess threads add context-switching overhead with no throughput gain. Check navigator.hardwareConcurrency for the core count. A ThreadPool class that manages a fixed worker pool automatically is planned for a future JOPS release.


CLI

Command Description
npx jops-core init Vendor library, configure index.html, inject scripts into package.json. Detects Cordova and Capacitor projects (see Hybrid Mobile Apps)
npx jops-core update Copy the latest jops-core.min.js from node_modules to lib/
npx jops-core build Generate production-ready dist/ folder (skipped in Cordova/Capacitor projects)

init and update accept --cordova, --capacitor or --web to override the detected project type.

After each command the CLI checks npm for a newer jops-core and prints a notice if one is available (at most 1.5 s, silent when offline). build also warns if lib/jops-core.min.js doesn't match the installed version. Turn the check off with --no-update-check or JOPS_NO_UPDATE_CHECK=1; it is skipped automatically when CI is set.


Source & Contributing

JOPS is distributed as a single minified ES module (lib/jops-core.min.js) together with full TypeScript declarations (lib/jops-core.d.ts). The source repository is private.

This is a deliberate choice. JOPS is maintained by one developer at UIDB, and keeping the codebase in one pair of hands keeps it small, consistent, and fast to change.

What that means for you:

  • Free to use. JOPS is MIT-licensed. Use it in personal, commercial, and client projects.
  • The API is the contract. Every public class, method, and lifecycle hook is documented in this README and typed in jops-core.d.ts, so your editor gives you autocomplete and inline docs.
  • Readable stack traces. The build keeps class and function names (--keep-names), so errors point to View.loadLayout, not e.t.
  • Backwards compatible within a major version. Pin jops-core@1 if you want to control major upgrades.
  • Feedback goes through issues. Bug reports and feature requests are welcome in GitHub Issues, and questions in Discussions. The repository doesn't accept pull requests; describe the change in an issue instead.

About

No-bundler, OOP-first SPA framework with Android-style views

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages