A no-bundler, vanilla-JavaScript SPA framework for developers who think in OOP and native-mobile patterns.
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.
| No Bundler | OOP Classes | Declarative View Nesting | No Element Registration | |
|---|---|---|---|---|
| JOPS | ✅ | ✅ | ✅ | ✅ |
| Lit | ✅ | ✅ | ❌ | |
| 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.
| Demo | Description |
|---|---|
| JOPS Hello World | A minimal starter app showing views, layouts, routing, and event binding |
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-clickingindex.html. Browsers blockimportonfile://URLs.
For editor autocomplete and inline docs without installing the full package, add jops-core as a dev dependency:
npm i -D jops-coreYour editor picks up lib/jops-core.d.ts automatically via the "types" field in package.json.
For greenfield projects that want local tooling and scaffolding:
npm init -y
npm install jops-core
npx jops-core initnpx 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.
When a new version of JOPS is released, run both commands:
npm i jops-core@latest
npx jops-core updatenpm 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.
npm run buildGenerates a clean dist/ folder ready to serve.
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.
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 |
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 android2. 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-core3. Run init from the project root (running it from inside www/ / your webDir works too):
npx jops-core initinit 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).
| 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
initagain — including throughpostinstall— never duplicates anything. -
Cordova's sample page: when you pick the Empty or Hello World template and
www/index.htmlis still the page created bycordova create,initrenames it towww/index.cordova.htmland writes a JOPSindex.htmlinstead (the sample'sjs/index.jsexpects markup that a JOPS app replaces). No files are deleted;css/,img/andjs/from the sample can be removed by hand. -
iOS scheme (Cordova): JOPS loads
App.js, views and layouts with nativeimport()andfetch(), which WebViews block onfile://pages. Cordova Android and Capacitor already serve fromhttps://localhost; for Cordova iOS,initadds the following toconfig.xml(applied on the nextcordova 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.
initwarns if the iOS scheme isfile,httporhttps, or ifAndroidInsecureFileModeEnabledistrue, since both keep the app onfile://. Already-published apps: the new origin cannot see data stored underfile://(localStorage, IndexedDB), so plan a migration if your app stores data locally. -
npx jops-core updatewrites to the same web folder, andnpx jops-core buildis skipped in hybrid projects (the web folder is shipped as-is). -
Override the detection with
--cordova,--capacitoror--web(for examplenpx jops-core init --webto 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>In Cordova and Capacitor, JOPS connects the Android back button (key and gesture) to the Router automatically:
- If
Router.singleton().canGoBack()istrue, it callsRouter.back()— the current view receivesonBackPressed()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 syncCapacitor'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 firesbackbutton, 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();
});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.
- Android: open
chrome://inspectin 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.
The foundational building block. Every screen, component, and sub-component extends View.
import { View } from "jops-core";
export default class MyView extends View { ... }| 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 |
| 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 |
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) { ... }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();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 HTMLIn 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:
.htmllayouts are fetched at runtime and cached in memory — identical dedup behaviour to the browser's module cache for.jslayouts. Each unique path is fetched once per page load.
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``Hash-based SPA router. Singleton. Declared in the layout as <Router jops animation="slide">.
| 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) |
| Method | Description |
|---|---|
Router.singleton() |
Returns the single Router instance |
| 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 |
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.
<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"andtype="jops-event-bind"syntax is still supported for backwards compatibility.
// 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();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();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);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
Threadinstance 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. Checknavigator.hardwareConcurrencyfor the core count. AThreadPoolclass that manages a fixed worker pool automatically is planned for a future JOPS release.
| 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.
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 toView.loadLayout, note.t. - Backwards compatible within a major version. Pin
jops-core@1if 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.