jops-core 1.0.14 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +346 -28
  2. package/bin/init.js +214 -21
  3. package/lib/jops-core.d.ts +251 -15
  4. package/lib/jops-core.min.js +1 -1
  5. package/lib/nextjs/JopsApp.d.ts +35 -0
  6. package/lib/nextjs/JopsApp.js +50 -0
  7. package/package.json +33 -5
  8. package/templates/nextjs-jops-spa/README.md +34 -0
  9. package/templates/nextjs-jops-spa/data/quotes.json +62 -0
  10. package/templates/nextjs-jops-spa/gitignore +21 -0
  11. package/templates/nextjs-jops-spa/next.config.ts +15 -0
  12. package/templates/nextjs-jops-spa/package.json +25 -0
  13. package/templates/nextjs-jops-spa/public/jops-spa/App.js +10 -0
  14. package/templates/nextjs-jops-spa/public/jops-spa/Login.js +26 -0
  15. package/templates/nextjs-jops-spa/public/jops-spa/Quotes.js +102 -0
  16. package/templates/nextjs-jops-spa/public/jops-spa/api.js +15 -0
  17. package/templates/nextjs-jops-spa/public/jops-spa/badge.js +8 -0
  18. package/templates/nextjs-jops-spa/public/jops-spa/format.js +5 -0
  19. package/templates/nextjs-jops-spa/public/jops-spa/layout/login.html +34 -0
  20. package/templates/nextjs-jops-spa/public/jops-spa/layout/main.html +17 -0
  21. package/templates/nextjs-jops-spa/public/jops-spa/layout/quotes.html +50 -0
  22. package/templates/nextjs-jops-spa/public/jops-spa/layout/quotes_row.html +8 -0
  23. package/templates/nextjs-jops-spa/public/jops-spa/skeleton.js +7 -0
  24. package/templates/nextjs-jops-spa/src/app/about/page.tsx +59 -0
  25. package/templates/nextjs-jops-spa/src/app/api/quotes/route.ts +11 -0
  26. package/templates/nextjs-jops-spa/src/app/globals.css +6 -0
  27. package/templates/nextjs-jops-spa/src/app/jops-spa/page.tsx +11 -0
  28. package/templates/nextjs-jops-spa/src/app/layout.tsx +27 -0
  29. package/templates/nextjs-jops-spa/src/app/page.tsx +5 -0
  30. package/templates/nextjs-jops-spa/tsconfig.json +21 -0
@@ -1,3 +1,32 @@
1
+ /**
2
+ * Starts a JOPS app inside a given element instead of `<body>`. Used by hosts
3
+ * that control when and where the app runs, such as `<JopsApp>` in Next.js
4
+ * (jops-core loaded with `data-host`, which exposes `window.JOPS.mount`).
5
+ * @param {{ app: string, root: HTMLElement }} options - `app`: URL of the app's
6
+ * entry module (default export is the App class); `root`: element the app's
7
+ * root view renders into.
8
+ * @returns {Promise<object>} The App instance.
9
+ * @example
10
+ * await mount({ app: "/quotes/App.js", root: document.getElementById("quotes") });
11
+ */
12
+ export declare function mount({ app, root }: {
13
+ app: string;
14
+ root: HTMLElement;
15
+ }): Promise<object>;
16
+ /**
17
+ * Ends the hosted app completely, like closing a standalone app: destroys the
18
+ * Router (listeners, singleton, routes, stacked views) and every view, empties
19
+ * the host root, clears the Store and the EventBus, and restores the host's
20
+ * history methods. The current URL and history entry are left alone: they
21
+ * belong to the host page. Safe to call more than once.
22
+ *
23
+ * Called by the Router itself when history leaves the app's page (Back past
24
+ * the entry the app was launched from, or a host link to another page), and
25
+ * by hosts such as `<JopsApp>` when they remove the app.
26
+ * @example
27
+ * window.JOPS.unmount();
28
+ */
29
+ export declare function unmount(): void;
1
30
  declare class RawHTML {
2
31
  html: any;
3
32
  constructor(html: any);
@@ -26,13 +55,43 @@ export declare function layout(strings: TemplateStringsArray, ...values: any[]):
26
55
  * Wraps a single child element for reading and writing.
27
56
  */
28
57
  declare class Element {
29
- element: any;
30
- constructor(domNode: any, id: any);
58
+ #private;
59
+ id: string;
60
+ inert: boolean;
61
+ element: globalThis.Element;
31
62
  /**
32
- * Sets the `innerHTML` of the element.
33
- * @param {string} value - Plain text or HTML string.
63
+ * @param {HTMLElement[]} roots - The view's root elements, searched in order.
64
+ * @param {string} id - The id to look up; the first root that has it wins.
34
65
  */
35
- set(value: string): void;
66
+ constructor(roots: HTMLElement[], id: string);
67
+ /**
68
+ * Wraps an element that was already found (used by `View.find(id)`).
69
+ * @param {HTMLElement} element
70
+ * @param {string} id
71
+ * @returns {Element}
72
+ */
73
+ static from(element: HTMLElement, id: string): Element;
74
+ /**
75
+ * Sets the `innerHTML` of the element. The value is not escaped: pass values
76
+ * from users or the network through the `layout` tag.
77
+ * @param {string} html - Plain text or HTML string.
78
+ * @returns {Element} This wrapper, for chaining.
79
+ * @example
80
+ * this.get("title").set(layout`${quote.number}`);
81
+ */
82
+ set(html: string): Element;
83
+ /**
84
+ * Sets an attribute, or removes it when `value` is `null` or `undefined`.
85
+ * An empty string sets a boolean attribute (`disabled`, `hidden`, `required`).
86
+ * @param {string} name - Attribute name, e.g. "href".
87
+ * @param {string|number|boolean|null} [value] - Attribute value.
88
+ * @returns {Element} This wrapper, for chaining.
89
+ * @example
90
+ * this.get("quote_id").set(layout`${q.number}`).attr("href", `#quote?id=${q.id}`);
91
+ * this.get("save").attr("disabled", ""); // set
92
+ * this.get("save").attr("disabled", null); // remove
93
+ */
94
+ attr(name: string, value?: string | number | boolean | null): Element;
36
95
  /**
37
96
  * Returns the current `innerHTML` of the element.
38
97
  * @returns {string}
@@ -46,9 +105,16 @@ declare class Element {
46
105
  * 1. {@link View#loadLayout} — load or assign `this.layout`
47
106
  * 2. {@link View#inflate} — parse `this.layout` into `this.domNode`
48
107
  * 3. {@link View#onLayout} — DOM is live; add subviews, read initial data
49
- * 4. {@link View#onResume} — view becomes visible; called again on every return navigation
108
+ * 4. {@link View#onResume} — view becomes visible; called again on every return navigation,
109
+ * and when the same route is shown again with new parameters
50
110
  * 5. {@link View#bindEvents} — wire `on*` attributes to class methods
51
111
  *
112
+ * Optional hooks, also called by the framework:
113
+ * - `onPause()` — a different view comes to the front and this one stays alive
114
+ * (hidden route, or covered by a stacked view). Not called when the view is destroyed
115
+ * (Back from a stacked view, a stack closed by a flat link, unmount()).
116
+ * - `onBackPressed()` — Back navigation from this view (called before onPause() for routes).
117
+ *
52
118
  * @example
53
119
  * import { View, layout } from "jops-core";
54
120
  *
@@ -63,17 +129,35 @@ declare class Element {
63
129
  */
64
130
  export declare class View {
65
131
  #private;
132
+ _generation: number;
66
133
  subViews: any[];
67
134
  layout: any;
68
135
  style: string;
136
+ domNodes: any[];
69
137
  domNode: any;
138
+ _destroyed: boolean;
139
+ _borrowedDomNode: boolean;
70
140
  /**
71
141
  * @param {string} [layout] - Optional layout path or inline HTML string.
142
+ * @param {Record<string, string>} [props] - Input properties. A view declared
143
+ * in a layout (`<Card jops src="…" title="Hi">`) gets them from the tag's
144
+ * attributes automatically; a view created in code may pass them here.
72
145
  */
73
- constructor(layout?: string);
146
+ constructor(layout?: string, props?: Record<string, string>);
147
+ /**
148
+ * The view's input properties (read-only): the attributes of its declared
149
+ * jops tag (except `jops`, `type="jops"`, `src`, `layout`, `path`,
150
+ * `jops-event-bind`), names in camelCase, values as strings; or the props
151
+ * passed to the constructor. `{}` when there are none.
152
+ * @returns {Readonly<Record<string, string>>}
153
+ */
154
+ get props(): Readonly<Record<string, string>>;
74
155
  /**
75
156
  * Override to load a layout file or set `this.layout` inline.
76
157
  * Called automatically by the framework before `inflate()`.
158
+ * Loads once per view: overlapping calls (with or without a path) wait for
159
+ * the load in progress, and once a layout is set later calls return at once.
160
+ * A failed load leaves the view unloaded, so it can be retried.
77
161
  * @param {string} [layout] - Path to a `.js` layout module, a `.html` file, or an inline HTML string.
78
162
  * `.js` — dynamically imported as an ES module; must export a `layout\`...\`` string as default.
79
163
  * `.html` — fetched and cached for the page lifetime; plain HTML fragment, no import/export needed.
@@ -83,6 +167,7 @@ export declare class View {
83
167
  loadLayout(layout?: string): Promise<void>;
84
168
  /**
85
169
  * Injects a `.css` file link or an inline `<style>` tag into `<head>`, deduped.
170
+ * No effect in hosted mode (e.g. Next.js through `<JopsApp>`): the host owns the CSS.
86
171
  * @param {string} style - Path to a `.css` file, or a raw CSS string.
87
172
  */
88
173
  setStyle(style: string): void;
@@ -94,6 +179,19 @@ export declare class View {
94
179
  * this.get("username").set("Alice");
95
180
  */
96
181
  get(id: string): Element;
182
+ /**
183
+ * The view's root elements: every top-level element of its layout (a layout
184
+ * may have more than one), or the borrowed container of a root view.
185
+ * @returns {HTMLElement[]}
186
+ * @protected
187
+ */
188
+ protected _roots(): HTMLElement[];
189
+ /**
190
+ * Hides or shows every root element (used by the Router).
191
+ * @param {boolean} hidden
192
+ * @protected
193
+ */
194
+ protected _setHidden(hidden: boolean): void;
97
195
  /**
98
196
  * Wires `on*` HTML attributes on `[jops-event-bind]` elements to
99
197
  * same-named methods on this view instance.
@@ -108,6 +206,7 @@ export declare class View {
108
206
  /**
109
207
  * Appends `view` into the child element with `id="parentNode"` and runs its
110
208
  * full lifecycle (`onLayout` → `onResume` → `bindEvents`).
209
+ * A removed (destroyed) view is final: create a new instance to show it again.
111
210
  * @param {string} parentNode - The `id` of the container element inside `this.domNode`.
112
211
  * @param {View} view - The subview to mount.
113
212
  * @returns {Promise<void>}
@@ -121,14 +220,50 @@ export declare class View {
121
220
  * Removes the inline `display` override, restoring `this.domNode` visibility.
122
221
  */
123
222
  show(): void;
223
+ /**
224
+ * Every element with the given id across all root elements of the layout
225
+ * (and the DOM of its sub-views), in page order.
226
+ * @overload
227
+ * @param {string} id - Element id.
228
+ * @returns {Element[]}
229
+ * @example
230
+ * this.find("quote_id").forEach((el) => el.attr("tabindex", 0));
231
+ */
124
232
  /**
125
233
  * Depth-first search across the full subview subtree.
126
- * @param {function(View): boolean} condition - Predicate to match against each view.
234
+ * @overload
235
+ * @param {(view: View) => boolean} condition - Predicate to match against each view.
127
236
  * @returns {View[]} Flat array of all matching views.
128
237
  * @example
129
238
  * const forms = root.find(v => v instanceof FormView);
130
239
  */
131
- find(condition: Function): View[];
240
+ /**
241
+ * @param {string | ((view: View) => boolean)} arg
242
+ * @returns {Element[] | View[]}
243
+ */
244
+ find(id: string): Element[];
245
+ /**
246
+ * Every element with the given id across all root elements of the layout
247
+ * (and the DOM of its sub-views), in page order.
248
+ * @overload
249
+ * @param {string} id - Element id.
250
+ * @returns {Element[]}
251
+ * @example
252
+ * this.find("quote_id").forEach((el) => el.attr("tabindex", 0));
253
+ */
254
+ /**
255
+ * Depth-first search across the full subview subtree.
256
+ * @overload
257
+ * @param {(view: View) => boolean} condition - Predicate to match against each view.
258
+ * @returns {View[]} Flat array of all matching views.
259
+ * @example
260
+ * const forms = root.find(v => v instanceof FormView);
261
+ */
262
+ /**
263
+ * @param {string | ((view: View) => boolean)} arg
264
+ * @returns {Element[] | View[]}
265
+ */
266
+ find(condition: (view: View) => boolean): View[];
132
267
  /**
133
268
  * Recursively destroys the subview subtree: calls `destroy()` on all
134
269
  * subviews, removes `this.domNode` from the DOM, and nulls references.
@@ -141,11 +276,21 @@ export declare class View {
141
276
  removeSubView(view: View): void;
142
277
  /**
143
278
  * Inserts `view` at `index` within `this.domNode` and runs its full lifecycle.
279
+ * A removed (destroyed) view is final: create a new instance to show it again.
144
280
  * @param {View} view - The subview to insert.
145
281
  * @param {number} index - Zero-based insertion position.
146
282
  * @returns {Promise<void>}
147
283
  */
148
284
  insertSubView(view: View, index: number): Promise<void>;
285
+ /**
286
+ * Final lifecycle steps for a sub-view declared in this view's layout,
287
+ * called by `render()` after its `onLayout()`. Router overrides it so a
288
+ * route is resumed only when it is shown.
289
+ * @param {View} subView
290
+ * @returns {Promise<void>}
291
+ * @protected
292
+ */
293
+ protected _startSubView(subView: View): Promise<void>;
149
294
  /**
150
295
  * Mounts the view to the DOM, resolves all nested `[jops]` view
151
296
  * elements and `<Router>` declarations, and runs each one's full lifecycle.
@@ -159,30 +304,83 @@ export declare class View {
159
304
  * Static pub/sub event bus for cross-view communication.
160
305
  * All methods are static — no instance needed.
161
306
  *
307
+ * A subscription with an owner (a view) is removed automatically when the
308
+ * owner is destroyed; a global subscription (no owner) stays until it is
309
+ * unsubscribed (the developer's responsibility).
310
+ *
162
311
  * @example
163
- * EventBus.subscribe("user:login", data => console.log(data.name));
312
+ * EventBus.subscribe(this, "quotes:changed", (q) => this.refresh(q)); // owned
313
+ * EventBus.subscribe("user:login", onLogin); // global
164
314
  * EventBus.send("user:login", { name: "Alice" });
165
315
  */
166
316
  export declare class EventBus {
167
317
  #private;
168
318
  /**
169
- * Registers a listener for the named event.
319
+ * Registers a global listener; unsubscribe it with `unsubscribe()`.
320
+ * @overload
321
+ * @param {string} event - Event name.
322
+ * @param {(data: any) => void} callback - Handler called with the event payload.
323
+ * @returns {void}
324
+ */
325
+ /**
326
+ * Registers a listener owned by `owner` (usually a view): it is removed
327
+ * automatically when the owner is destroyed.
328
+ * @overload
329
+ * @param {object} owner - The object owning the subscription, e.g. `this`.
330
+ * @param {string} event - Event name.
331
+ * @param {(data: any) => void} callback - Handler called with the event payload.
332
+ * @returns {void}
333
+ */
334
+ /**
335
+ * @param {...*} args - `(event, callback)` or `(owner, event, callback)`.
336
+ * @returns {void}
337
+ */
338
+ static subscribe(event: string, callback: (data: any) => void): void;
339
+ /**
340
+ * Registers a global listener; unsubscribe it with `unsubscribe()`.
341
+ * @overload
342
+ * @param {string} event - Event name.
343
+ * @param {(data: any) => void} callback - Handler called with the event payload.
344
+ * @returns {void}
345
+ */
346
+ /**
347
+ * Registers a listener owned by `owner` (usually a view): it is removed
348
+ * automatically when the owner is destroyed.
349
+ * @overload
350
+ * @param {object} owner - The object owning the subscription, e.g. `this`.
170
351
  * @param {string} event - Event name.
171
- * @param {function(*): void} callback - Handler called with the event payload.
352
+ * @param {(data: any) => void} callback - Handler called with the event payload.
353
+ * @returns {void}
172
354
  */
173
- static subscribe(event: string, callback: Function): void;
174
355
  /**
175
- * Fires the named event to all subscribers.
356
+ * @param {...*} args - `(event, callback)` or `(owner, event, callback)`.
357
+ * @returns {void}
358
+ */
359
+ static subscribe(owner: object, event: string, callback: (data: any) => void): void;
360
+ /**
361
+ * Fires the named event to all subscribers. Subscriptions of destroyed
362
+ * owners are skipped and removed (a safety net: `destroy()` normally
363
+ * removes them first).
176
364
  * @param {string} event - Event name.
177
365
  * @param {*} [data] - Optional payload passed to every subscriber.
178
366
  */
179
367
  static send(event: string, data?: any): void;
180
368
  /**
181
- * Removes a specific listener by reference.
369
+ * Removes a specific listener by reference (global or owned).
182
370
  * @param {string} event - Event name.
183
371
  * @param {function(*): void} callback - The exact function reference passed to `subscribe`.
184
372
  */
185
373
  static unsubscribe(event: string, callback: Function): void;
374
+ /**
375
+ * Removes every subscription owned by `owner`, across all events (called by
376
+ * `View.destroy()`). Global subscriptions are not touched.
377
+ * @param {object} owner - The owner passed to `subscribe(owner, event, callback)`.
378
+ */
379
+ static unsubscribeAll(owner: object): void;
380
+ /**
381
+ * Removes every listener of every event (called by `unmount()`).
382
+ */
383
+ static clear(): void;
186
384
  }
187
385
  /**
188
386
  * App-wide runtime state container. Singleton. No reactivity.
@@ -244,7 +442,10 @@ export declare class Router extends View {
244
442
  routes: {};
245
443
  _navStack: any[];
246
444
  _baseView: any;
445
+ _defaultPath: string;
446
+ _pathname: string;
247
447
  _idx: any;
448
+ _historyListener: () => void;
248
449
  _currentView: any;
249
450
  /**
250
451
  * Returns the single Router instance after it has been rendered.
@@ -253,6 +454,15 @@ export declare class Router extends View {
253
454
  static singleton(): Router;
254
455
  constructor(routerDomNode: any);
255
456
  onLayout(): Promise<void>;
457
+ /**
458
+ * Routes only get their events bound while the Router renders; `onResume()`
459
+ * runs when a route is shown (`_switchTo()` and the transitions), so hidden
460
+ * routes are not resumed at startup.
461
+ * @param {View} subView
462
+ * @returns {Promise<void>}
463
+ * @protected
464
+ */
465
+ protected _startSubView(subView: View): Promise<void>;
256
466
  initRouter(): void;
257
467
  /**
258
468
  * Single entry point for popstate and hashchange. Decides the direction
@@ -261,7 +471,33 @@ export declare class Router extends View {
261
471
  _onHistoryChange(): void;
262
472
  _onBack(): void;
263
473
  _onForward(): void;
474
+ /** Destroys every stacked navigate() view (switching to a flat route). */
475
+ _closeStack(): void;
476
+ /**
477
+ * onPause() on the view leaving the front: a different view is coming to
478
+ * the front and the current one stays alive (hidden or covered). Not used
479
+ * when a view is destroyed (stack pop on Back, _closeStack(), unmount()).
480
+ * @param {View} incoming
481
+ */
482
+ _pauseCurrent(incoming: View): void;
264
483
  _switchToHash(): void;
484
+ /**
485
+ * Replaces the current entry's empty hash with the default (first) route,
486
+ * keeping the existing history state, and shows that route.
487
+ */
488
+ _switchToDefault(): void;
489
+ /**
490
+ * Removes the history listeners, destroys the routes and stacked views and
491
+ * clears the singleton. Called by `unmount()`; safe to call more than once.
492
+ */
493
+ destroy(): void;
494
+ /** pushState/replaceState marked as JOPS's own (see installHostHistoryHook). */
495
+ _history(method: any, state: any, url: any): void;
496
+ /**
497
+ * Hosted mode: called after the host changed the URL with pushState or
498
+ * replaceState (no browser event fires for those).
499
+ */
500
+ _onHostNavigation(): void;
265
501
  _animateTransition(outgoing: any, incoming: any, direction: any): Promise<void>;
266
502
  _slideTransition(outgoing: any, incoming: any, direction: any): Promise<void>;
267
503
  _fadeTransition(outgoing: any, incoming: any): Promise<void>;
@@ -1 +1 @@
1
- var R=Object.defineProperty;var d=(o,t)=>R(o,"name",{value:t,configurable:!0});var v=new URL(import.meta.url).origin!==location.origin,w=new Map;function f(o){if(!o)return o;if(v){if(o.startsWith("/"))return"."+o}else if(o.startsWith("./"))return o.slice(1);return o}d(f,"normalizePath");(()=>{console.log("JOPS initialized");let o=document.querySelector("script[data-app]"),t=o?.dataset.app??"./src/App.js";import(new URL(f(t),document.baseURI).href).then(e=>new e.default).catch(e=>console.error("JOPS: App.js not found or failed to load",e)),o?.dataset.nativeBack!=="off"&&V()})();function V(){document.addEventListener("deviceready",()=>{let o=window.Capacitor;o?.isNativePlatform?.()&&o.getPlatform()==="android"&&!o.isPluginAvailable("App")&&console.warn("JOPS: install @capacitor/app so the Android back button navigates instead of closing the app.");let t=0;document.addEventListener("backbutton",()=>{let e=Date.now();if(e-t<300)return;t=e;let s=m.singleton();s?.canGoBack()?s.back():!s&&window.navigation?.canGoBack?history.back():navigator.app.exitApp()})},{once:!0})}d(V,"installNativeBackButton");var p=class{static{d(this,"RawHTML")}constructor(t){this.html=t}},E=d(o=>new p(o),"raw");function P(o,...t){return o.reduce((e,s,i)=>{if(i>=t.length)return e+s;let n=t[i];return n==null?e+s:n instanceof p?e+s+n.html:e+s+String(n).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")},"")}d(P,"layout");var y=class{static{d(this,"Element")}constructor(t,e){this.element=t?.id===e?t:t.querySelector(`#${e}`)}set(t){this.element.innerHTML=t}value(){return this.element.innerHTML}},b=class o{static{d(this,"View")}#t=!1;#e=!1;constructor(t){this.loadLayout(t),this.subViews=[]}async loadLayout(t){if(t)if(t.endsWith(".js"))try{let e=await import(new URL(f(t),document.baseURI).href);this.layout=e.default}catch(e){console.error("JOPS: failed to load layout:",t,e)}else if(t.endsWith(".html")||t.endsWith(".htm"))try{let e=new URL(f(t),document.baseURI).href;if(w.has(e))this.layout=w.get(e);else{let s=await fetch(e);if(!s.ok)throw new Error(`HTTP ${s.status}`);let i=await s.text();w.set(e,i),this.layout=i}}catch(e){console.error("JOPS: failed to load layout:",t,e)}else this.layout=t}setStyle(t){if(t)if(this.style=t,t.endsWith(".css")){if(document.querySelector(`link[href="${t}"]`))return;let e=document.createElement("link");e.rel="stylesheet",e.href=t,document.head.appendChild(e)}else{if(document.querySelector(`style[data-jops="${t}"]`))return;let e=document.createElement("style");e.setAttribute("data-jops",t),e.textContent=t,document.head.appendChild(e)}}get(t){return new y(this.domNode,t)}bindEvents(){if(!this.domNode)return;let t=[...this.domNode.querySelectorAll("[jops-event-bind], [type='jops-event-bind']")];(this.domNode.hasAttribute("jops-event-bind")||this.domNode.getAttribute("type")==="jops-event-bind")&&t.unshift(this.domNode);for(let e of t)for(let s of e.attributes){if(!s.name.startsWith("on"))continue;let i=s.value.trim();typeof this[i]=="function"&&(e[s.name]=n=>this[i](n))}}inflate(){if(!this.layout||this.#t)return;this.#t=!0;let t=document.createElement("template");t.innerHTML=this.layout;let s=t.content.cloneNode(!0).firstElementChild;this.domNode=s}async addSubView(t,e){this.subViews.push(e),e.domNode||(await e.loadLayout(),e.inflate()),this.domNode.querySelector(`#${t}`).appendChild(e.domNode),await e?.onLayout?.(),await e?.onResume?.(),e?.bindEvents?.()}hide(){this.domNode.style.display="none"}show(){this.domNode.style.display=""}find(t){let e=[];for(let s of this.subViews)t(s)&&e.push(s),e.push(...s.find?.(t)??[]);return e}destroy(){this.subViews.forEach(t=>t.destroy?.()),this.subViews=[],this.domNode?.remove(),this.domNode=null,this.layout=null}removeSubView(t){t.destroy?.(),this.subViews=this.subViews.filter(e=>e!==t)}async insertSubView(t,e){t.domNode||(await t.loadLayout(),t.inflate());let s=this.domNode.children[e]??null;this.domNode.insertBefore(t.domNode,s),this.subViews.splice(e,0,t),await t?.onLayout?.(),await t?.onResume?.(),t?.bindEvents?.()}async render(){if(!this.layout||this.#e)return;this.#e=!0;let t=this.domNode??document.body;t===document.body&&(t.innerHTML=this.layout);let e=Array.from(t.querySelectorAll("[jops], [type='jops']")).filter(s=>s.tagName==="ROUTER"||!s.closest("router")||s.closest("router")===t);e.length!==0&&await Promise.all(e.map(async s=>{if(s.tagName==="ROUTER"){let c=new m(s);await c.render(),await c.onLayout();return}let i=s.getAttribute("layout"),n=s.getAttribute("src");if(!n&&!i)return;let r;if(n){let l=(await import(new URL(f(n),document.baseURI).href)).default;r=new l}else r=new o;this.subViews.push(r),await r.loadLayout(i);let a=document.createElement("template");a.innerHTML=r.layout??"";let h=a.content.cloneNode(!0);if(s.children.length>0){let c=h.firstElementChild??h;Array.from(s.children).forEach(l=>c.appendChild(l))}let u=h.firstElementChild;r.routePath=s.getAttribute("path"),s.replaceWith(h),r.domNode=u,await r.render(),await r?.onLayout?.(),await r?.onResume?.(),r?.bindEvents?.()}))}},_=class o{static{d(this,"EventBus")}static#t={};static subscribe(t,e){o.#t[t]||(o.#t[t]=[]),o.#t[t].push(e)}static send(t,e){o.#t[t]?.forEach(s=>s(e))}static unsubscribe(t,e){o.#t[t]&&(o.#t[t]=o.#t[t].filter(s=>s!==e))}},S=class o{static{d(this,"Store")}static#t=null;static singleton(){return o.#t||(o.#t=new o),o.#t}constructor(){this._state={}}get(t){return t.split(".").reduce((e,s)=>e?.[s],this._state)}set(t,e){let s=t.split("."),i=s.pop(),n=s.reduce((r,a)=>(r[a]||(r[a]={}),r[a]),this._state);n[i]=e}clear(){this._state={}}},m=class o extends b{static{d(this,"Router")}static#t=null;static singleton(){return o.#t}constructor(t){super(),o.#t=this,this.rootNode=t.parentNode,this.routerDomNode=t,this.domNode=t,this.layout=t.innerHTML,this.animation=t.getAttribute("animation")??"none"}async onLayout(){let t=document.createDocumentFragment();for(;this.routerDomNode.firstChild;)t.appendChild(this.routerDomNode.firstChild);this.routerDomNode.replaceWith(t),this.domNode=this.rootNode,this.initRouter()}initRouter(){this.routes={};for(let r of this.subViews)if(r.routePath){let a=r.routePath.slice(1).split("?")[0];this.routes[a]=r}this._navStack=[],this._baseView=null;for(let r of Object.values(this.routes))r.domNode.hidden=!0;let t=Object.keys(this.routes)[0],e=window.location.hash.slice(1),s=e.split("?")[0],i=s&&this.routes[s]?e:t;this._idx=history.state?.jopsIdx??0,history.replaceState({...history.state,jopsIdx:this._idx},"","#"+i);let n=d(()=>this._onHistoryChange(),"onHistoryChange");window.addEventListener("popstate",n),window.addEventListener("hashchange",n),this._switchTo(i)}_onHistoryChange(){let t=history.state?.jopsIdx;if(t===this._idx)return;if(t===void 0){this._idx+=1,history.replaceState({...history.state,jopsIdx:this._idx},"",location.href),this._onForward();return}let e=t<this._idx;this._idx=t,e?this._onBack():this._onForward()}_onBack(){if(this._navStack.length>0){let e=this._navStack.pop(),s=this._navStack.at(-1)??this._baseView;e?.onBackPressed?.(),this._animateTransition(e,s,"back").then(()=>e.destroy());return}let t=window.location.hash.slice(1).split("?")[0];if(!this.routes[t]){history.back();return}this._currentView?.onBackPressed?.(),this._switchToHash()}_onForward(){this._navStack.length>0&&(this._navStack.forEach(t=>t.destroy?.()),this._navStack=[],this._baseView=null),this._switchToHash()}_switchToHash(){let t=window.location.hash.slice(1),e=t.split("?")[0];e&&this.routes[e]&&this._switchTo(t)}async _animateTransition(t,e,s){this.animation==="slide"?await this._slideTransition(t,e,s):this.animation==="fade"?await this._fadeTransition(t,e):(t.domNode.hidden=!0,e.domNode.hidden=!1,this._currentView=e,e?.onResume?.())}async _slideTransition(t,e,s){let i=this.domNode,n=s==="forward",r=i.getBoundingClientRect(),a=t.domNode.getBoundingClientRect(),h=a.left-r.left,u=a.top-r.top,c=a.width,l=i.offsetWidth-h,N=`position:absolute;left:${h}px;top:${u}px;width:${c}px`;i.style.cssText=`position:relative;overflow:hidden;height:${i.offsetHeight}px`,t.domNode.style.cssText=`${N};z-index:${n?1:2}`,t.domNode.hidden=!1,e.domNode.style.cssText=`${N};z-index:${n?2:1}`,e.domNode.hidden=!1;let T=n?e.domNode:t.domNode,g=n?`${l}px`:"0px",L=n?"0px":`${l}px`,x=T.animate([{transform:`translateX(${g})`},{transform:`translateX(${L})`}],{duration:300,easing:"ease",fill:"forwards"});await x.finished,x.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",i.style.cssText="",this._currentView=e,e?.onResume?.()}async _fadeTransition(t,e){let s=this.domNode,i=s.getBoundingClientRect(),n=t.domNode.getBoundingClientRect(),r=n.left-i.left,a=n.top-i.top,h=n.width,u=`position:absolute;left:${r}px;top:${a}px;width:${h}px`;s.style.cssText=`position:relative;overflow:hidden;height:${s.offsetHeight}px`,t.domNode.style.cssText=u,t.domNode.hidden=!1,e.domNode.style.cssText=u,e.domNode.hidden=!1;let c=t.domNode.animate([{opacity:1},{opacity:0}],{duration:450,easing:"ease",fill:"forwards"}),l=e.domNode.animate([{opacity:0},{opacity:1}],{duration:450,easing:"ease",fill:"forwards"});await Promise.all([c.finished,l.finished]),c.cancel(),l.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",s.style.cssText="",this._currentView=e,e?.onResume?.()}_switchTo(t){let e=t.split("?")[0];for(let[s,i]of Object.entries(this.routes)){let n=s===e;i.domNode.hidden=!n,n&&(this._currentView=i,i?.onResume?.())}}back(){history.back()}canGoBack(){return this._idx>0}getParams(){let t=window.location.hash.split("?")[1];return t?Object.fromEntries(new URLSearchParams(t)):{}}async navigate(t,e){let[s,i]=t.replace(/^\//,"").split("?"),n=s,r=e??this.routes[n]?.constructor;if(!r){console.error(`JOPS Router: no class found for path "${t}"`);return}this._navStack.length===0&&(this._baseView=this._currentView);let a=new r;await a.loadLayout(),a.inflate(),this.domNode.appendChild(a.domNode),history.pushState({jopsNav:!0,jopsIdx:++this._idx},"","#"+n+(i?"?"+i:"")),await a.onLayout?.(),await a.onResume?.(),a.bindEvents?.(),this._navStack.push(a),this._animateTransition(this._currentView,a,"forward")}async replace(t,e){this._navStack.forEach(a=>a.destroy?.()),this._navStack=[];let[s,i]=t.replace(/^\//,"").split("?"),n=s,r="#"+n+(i?"?"+i:"");if(this.routes[n])history.replaceState({jopsIdx:this._idx},"",r),this._switchTo(n);else if(e){let a=new e;await a.loadLayout(),a.inflate(),this.domNode.appendChild(a.domNode),await a.onLayout?.(),await a.onResume?.(),a.bindEvents?.(),this._currentView&&(this._currentView.domNode.hidden=!0),this._currentView=a,history.replaceState({jopsIdx:this._idx},"",r)}else console.error(`JOPS Router: no instance or class found for path "${t}"`)}},k=class{static{d(this,"Thread")}#t=null;run(t){}onMessage(t){}start(t){if(typeof Worker>"u"){console.warn("JOPS Thread: Web Workers are not supported in this environment.");return}let e=this.run.toString();!e.startsWith("function")&&!e.startsWith("(")&&!e.startsWith("async")&&(e="function "+e);let s=new Blob([`const run = ${e};`,"self.onmessage = (e) => { run(e.data); };"],{type:"application/javascript"}),i=URL.createObjectURL(s);this.#t=new Worker(i),URL.revokeObjectURL(i),this.#t.onmessage=n=>this.onMessage(n),this.#t.postMessage(t)}terminate(){if(!this.#t){console.warn("JOPS Thread: no active worker to terminate.");return}this.#t.terminate(),this.#t=null}};export{_ as EventBus,m as Router,S as Store,k as Thread,b as View,P as layout,E as raw};
1
+ var W=Object.defineProperty;var h=(o,t)=>W(o,"name",{value:t,configurable:!0});var B=new URL(import.meta.url).origin!==location.origin,k=new Map;function b(o){if(!o)return o;if(B){if(o.startsWith("/"))return"."+o}else if(o.startsWith("./"))return o.slice(1);return o}h(b,"normalizePath");var u=null,S=!1,m=0,v=new Set,_=null,I=new Set(["jops","src","layout","path","jops-event-bind"]);function H(o){let t={};for(let{name:e,value:s}of o.attributes)I.has(e)||e==="type"&&s==="jops"||(t[e.replace(/-([a-z])/g,(n,i)=>i.toUpperCase())]=s);return t}h(H,"tagProps");var j=new Set,O=!1;(()=>{if(console.log("JOPS initialized"),Array.from(document.querySelectorAll("script[src]")).find(s=>s.src===import.meta.url)?.dataset.host!==void 0){S=!0,window.JOPS={mount:D,unmount:V},window.dispatchEvent(new Event("jops:ready"));return}let t=document.querySelector("script[data-app]"),e=t?.dataset.app??"./src/App.js";t?.dataset.root&&(u=document.getElementById(t.dataset.root),u||console.warn(`JOPS: data-root element #${t.dataset.root} not found, rendering into <body>`)),import(new URL(b(e),document.baseURI).href).then(s=>new s.default).catch(s=>console.error("JOPS: App.js not found or failed to load",s)),t?.dataset.nativeBack!=="off"&&K()})();var T=!1,N=!1,C={},$={};function M(){if(!N){N=!0;for(let o of["pushState","replaceState"]){let t=history[o],e=h(function(...s){let n=t.apply(this,s);return T||queueMicrotask(()=>y.singleton()?._onHostNavigation()),n},"wrapper");C[o]=t,$[o]=e,history[o]=e}}}h(M,"installHostHistoryHook");function q(){if(!N)return;let o=!0;for(let t of["pushState","replaceState"])history[t]===$[t]?history[t]=C[t]:o=!1;o&&(N=!1)}h(q,"uninstallHostHistoryHook");async function D({app:o,root:t}){if(!(t instanceof HTMLElement))throw new TypeError("JOPS mount(): root must be an HTMLElement");u=t,M();let e=m,s=await import(new URL(b(o),document.baseURI).href);return e!==m?null:new s.default}h(D,"mount");function V(){m+=1,y.singleton()?.destroy(),v.forEach(o=>o.destroy()),v.clear(),u&&(u.innerHTML=""),u=null,P.singleton().clear(),L.clear(),q()}h(V,"unmount");function K(){document.addEventListener("deviceready",()=>{let o=window.Capacitor;o?.isNativePlatform?.()&&o.getPlatform()==="android"&&!o.isPluginAvailable("App")&&console.warn("JOPS: install @capacitor/app so the Android back button navigates instead of closing the app.");let t=0;document.addEventListener("backbutton",()=>{let e=Date.now();if(e-t<300)return;t=e;let s=y.singleton();s?.canGoBack()?s.back():!s&&window.navigation?.canGoBack?history.back():navigator.app.exitApp()})},{once:!0})}h(K,"installNativeBackButton");var x=class{static{h(this,"RawHTML")}constructor(t){this.html=t}},z=h(o=>new x(o),"raw");function G(o,...t){return o.reduce((e,s,n)=>{if(n>=t.length)return e+s;let i=t[n];return i==null?e+s:i instanceof x?e+s+i.html:e+s+String(i).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")},"")}h(G,"layout");var g=class o{static{h(this,"Element")}constructor(t,e){this.id=e,this.inert=t.length===0,this.element=null;for(let s of t)if(this.element=s.id===e?s:s.querySelector(`[id="${CSS.escape(e)}"]`),this.element)break}static from(t,e){let s=new o([],e);return s.inert=!1,s.element=t,s}set(t){return this.element?(this.element.innerHTML=t,this):this.#t("set")}attr(t,e){return this.element?(e==null?this.element.removeAttribute(t):this.element.setAttribute(t,String(e)),this):this.#t("attr")}value(){return this.element?this.element.innerHTML:(this.#t("value"),"")}#t(t){return this.inert||console.warn(`JOPS: ${t}() on #${this.id}: no such element in the view`),this}},R=class o{static{h(this,"View")}#t=!1;#e=!1;#o=!1;#s=null;#i;constructor(t,e){this._generation=m;let s=_&&_.ClassDef===new.target?_.props:null;this.#i=Object.freeze({...e??s??{}}),this.loadLayout(t),this.subViews=[]}get props(){return this.#i}async loadLayout(t){if(this.#s)return this.#s;if(!(!t||this.#o))return this.#s=this.#n(t).finally(()=>{this.#o=this.layout!=null,this.#s=null}),this.#s}async#n(t){if(t.endsWith(".js"))try{let e=await import(new URL(b(t),document.baseURI).href);this.layout=e.default}catch(e){console.error("JOPS: failed to load layout:",t,e)}else if(t.endsWith(".html")||t.endsWith(".htm"))try{let e=new URL(b(t),document.baseURI).href;if(k.has(e))this.layout=k.get(e);else{let s=await fetch(e);if(!s.ok)throw new Error(`HTTP ${s.status}`);let n=await s.text();k.set(e,n),this.layout=n}}catch(e){console.error("JOPS: failed to load layout:",t,e)}else this.layout=t}setStyle(t){if(t){if(this.style=t,S){O||(O=!0,console.info("JOPS: setStyle() is ignored in hosted mode; the host provides the CSS."));return}if(t.endsWith(".css")){if(document.querySelector(`link[href="${CSS.escape(t)}"]`))return;let e=document.createElement("link");e.rel="stylesheet",e.href=t,document.head.appendChild(e)}else{if(j.has(t))return;j.add(t);let e=document.createElement("style");e.setAttribute("data-jops",""),e.textContent=t,document.head.appendChild(e)}}}get(t){return new g(this._roots(),t)}_roots(){return this.domNodes??(this.domNode?[this.domNode]:[])}_setHidden(t){for(let e of this._roots())e.hidden=t}bindEvents(){let t=this._roots().flatMap(e=>[...e.hasAttribute("jops-event-bind")||e.getAttribute("type")==="jops-event-bind"?[e]:[],...e.querySelectorAll("[jops-event-bind], [type='jops-event-bind']")]);for(let e of t)for(let s of e.attributes){if(!s.name.startsWith("on"))continue;let n=s.value.trim();typeof this[n]=="function"&&(e[s.name]=i=>this[n](i))}}inflate(){if(!this.layout||this.#t)return;this.#t=!0;let t=document.createElement("template");t.innerHTML=this.layout;let e=t.content.cloneNode(!0);this.domNodes=[...e.children],this.domNode=this.domNodes[0]}async addSubView(t,e){if(!this.domNode)return;if(e._destroyed){console.warn("JOPS: addSubView(): this view was destroyed; create a new instance");return}this.subViews.push(e),e.domNode||(await e.loadLayout(),e.inflate());let s=this.domNode?.querySelector(`#${t}`);if(!e.domNode||!s){this.subViews=this.subViews.filter(n=>n!==e),this.domNode&&console.warn(e.domNode?`JOPS: addSubView(): no element #${t} in the view`:"JOPS: addSubView(): the view has no layout");return}s.append(...e._roots()),await e?.onLayout?.(),await e?.onResume?.(),e?.bindEvents?.()}hide(){for(let t of this._roots())t.style.display="none"}show(){for(let t of this._roots())t.style.display=""}find(t){if(typeof t=="string")return this._roots().flatMap(s=>[...s.id===t?[s]:[],...s.querySelectorAll(`[id="${CSS.escape(t)}"]`)]).map(s=>g.from(s,t));let e=[];for(let s of this.subViews)t(s)&&e.push(s),e.push(...s.find?.(t)??[]);return e}destroy(){this._destroyed=!0,L.unsubscribeAll(this),this.subViews.forEach(e=>e.destroy?.()),this.subViews=[],this._borrowedDomNode||this.domNode&&(this.domNode===u||this.domNode===document.body)?this.domNode.innerHTML="":this._roots().forEach(e=>e.remove()),this.domNode=null,this.domNodes=null,this.layout=null}removeSubView(t){t.destroy?.(),this.subViews=this.subViews.filter(e=>e!==t)}async insertSubView(t,e){if(!this.domNode)return;if(t._destroyed){console.warn("JOPS: insertSubView(): this view was destroyed; create a new instance");return}if(t.domNode||(await t.loadLayout(),t.inflate()),!this.domNode)return;if(!t.domNode){console.warn("JOPS: insertSubView(): the view has no layout");return}let s=this.domNode.children[e]??null;for(let n of t._roots())this.domNode.insertBefore(n,s);this.subViews.splice(e,0,t),await t?.onLayout?.(),await t?.onResume?.(),t?.bindEvents?.()}async _startSubView(t){await t?.onResume?.(),t?.bindEvents?.()}async render(){if(!this.layout||this.#e||this._generation!==m)return;let t=!this.domNode;if(t&&S&&!u)return;this.#e=!0;let e=this.domNode??u??document.body;t&&(e.innerHTML=this.layout,v.add(this),this.domNode=e,this._borrowedDomNode=!0);let s=h(()=>this._generation!==m,"ended"),n=Array.from(e.querySelectorAll("[jops], [type='jops']")).filter(i=>i.tagName==="ROUTER"||!i.closest("router")||i.closest("router")===e);if(n.length>0&&await this.#r(n,e,s),!s()&&t){if(await this.onLayout?.(),s()||(await this.onResume?.(),s()))return;this.bindEvents()}}async#r(t,e,s){let n=new Array(t.length);await Promise.all(t.map(async(i,a)=>{if(i.tagName==="ROUTER"){let l=new y(i);if(await l.render(),s())return;await l.onLayout();return}let r=i.getAttribute("layout"),f=i.getAttribute("src");if(!f&&!r)return;let c;if(f){let l=await import(new URL(b(f),document.baseURI).href);if(s())return;let w=l.default;_={ClassDef:w,props:H(i)};try{c=new w}finally{_=null}}else c=new o(void 0,H(i));if(n[a]=c,await c.loadLayout(r),s())return;let p=document.createElement("template");p.innerHTML=c.layout??"";let d=p.content.cloneNode(!0);if(i.children.length>0){let l=d.firstElementChild??d;Array.from(i.children).forEach(w=>l.appendChild(w))}c.domNodes=[...d.children],c.routePath=i.getAttribute("path"),i.replaceWith(d),c.domNode=c.domNodes[0],await c.render(),!s()&&(await c?.onLayout?.(),!s()&&await this._startSubView(c))})),!s()&&this.subViews.push(...n.filter(Boolean))}},L=class o{static{h(this,"EventBus")}static#t={};static subscribe(...t){let[e,s,n]=typeof t[0]=="string"?[null,...t]:t;(o.#t[s]??=[]).push({callback:n,owner:e})}static send(t,e){let s=o.#t[t];if(s){o.#t[t]=s.filter(n=>!n.owner?._destroyed);for(let n of[...o.#t[t]])n.callback(e)}}static unsubscribe(t,e){o.#t[t]&&(o.#t[t]=o.#t[t].filter(s=>s.callback!==e))}static unsubscribeAll(t){for(let e of Object.keys(o.#t))o.#t[e]=o.#t[e].filter(s=>s.owner!==t)}static clear(){o.#t={}}},P=class o{static{h(this,"Store")}static#t=null;static singleton(){return o.#t||(o.#t=new o),o.#t}constructor(){this._state={}}get(t){return t.split(".").reduce((e,s)=>e?.[s],this._state)}set(t,e){let s=t.split("."),n=s.pop(),i=s.reduce((a,r)=>(a[r]||(a[r]={}),a[r]),this._state);i[n]=e}clear(){this._state={}}},y=class o extends R{static{h(this,"Router")}static#t=null;static#e=!1;static singleton(){return o.#t}constructor(t){super(),o.#t=this,this.rootNode=t.parentNode,this.routerDomNode=t,this.domNode=t,this.layout=t.innerHTML,this.animation=t.getAttribute("animation")??"none"}async onLayout(){let t=document.createDocumentFragment();for(;this.routerDomNode.firstChild;)t.appendChild(this.routerDomNode.firstChild);this.routerDomNode.replaceWith(t),this.domNode=this.rootNode,this.initRouter()}async _startSubView(t){t?.bindEvents?.()}initRouter(){if(this._generation!==m)return;this.routes={};for(let i of this.subViews)if(i.routePath){let a=i.routePath.slice(1).split("?")[0];this.routes[a]=i}this._navStack=[],this._baseView=null;for(let i of Object.values(this.routes))i._setHidden(!0);let t=Object.keys(this.routes)[0];this._defaultPath=t,this._pathname=location.pathname;let e=window.location.hash.slice(1),s=e.split("?")[0],n=s&&this.routes[s]?e:t;this._idx=history.state?.jopsIdx??0,this._history("replaceState",{...history.state,jopsIdx:this._idx},"#"+n),this._historyListener=()=>this._onHistoryChange(),window.addEventListener("popstate",this._historyListener),window.addEventListener("hashchange",this._historyListener),this._switchTo(n)}_onHistoryChange(){if(this._destroyed)return;if(location.pathname!==this._pathname){S&&V();return}let t=history.state?.jopsIdx;if(t===this._idx)return;if(t===void 0){this._idx+=1,this._history("replaceState",{...history.state,jopsIdx:this._idx},location.href),this._onForward();return}let e=t<this._idx;this._idx=t,e?this._onBack():this._onForward()}_onBack(){if(this._navStack.length>0){let e=this._navStack.pop(),s=this._navStack.at(-1)??this._baseView;e?.onBackPressed?.(),this._animateTransition(e,s,"back").then(()=>e.destroy());return}let t=window.location.hash.slice(1).split("?")[0];if(!this.routes[t]){history.back();return}this._currentView?.onBackPressed?.(),this._switchToHash()}_onForward(){this._closeStack(),this._switchToHash()}_closeStack(){this._navStack.length!==0&&(this._navStack.includes(this._currentView)&&(this._currentView=null),this._navStack.forEach(t=>t.destroy?.()),this._navStack=[],this._baseView=null)}_pauseCurrent(t){this._currentView&&this._currentView!==t&&this._currentView.onPause?.()}_switchToHash(){let t=window.location.hash.slice(1),e=t.split("?")[0];e?this.routes[e]&&this._switchTo(t):this._switchToDefault()}_switchToDefault(){this._history("replaceState",{...history.state,jopsIdx:this._idx},"#"+this._defaultPath),this._switchTo(this._defaultPath)}destroy(){this._destroyed||(this._destroyed=!0,this._historyListener&&(window.removeEventListener("popstate",this._historyListener),window.removeEventListener("hashchange",this._historyListener),this._historyListener=null),this._navStack?.forEach(t=>t.destroy?.()),this._navStack=[],this._baseView=null,this._currentView=null,this.routes={},o.#t===this&&(o.#t=null),super.destroy())}_history(t,e,s){T=!0;try{history[t](e,"",s)}finally{T=!1}}_onHostNavigation(){if(this._destroyed||!this.routes)return;if(location.pathname!==this._pathname){V();return}let t=location.hash.slice(1).split("?")[0],e=history.state?.jopsIdx===void 0;t?e&&this.routes[t]&&(this._idx+=1,this._history("replaceState",{...history.state,jopsIdx:this._idx},location.href),this._onForward()):(e&&(this._idx+=1),this._closeStack(),this._switchToDefault())}async _animateTransition(t,e,s){let n=s==="forward",i=t._roots().length>1||e._roots().length>1;i&&this.animation!=="none"&&!o.#e&&(o.#e=!0,console.info("JOPS: Router animations need single-root views; views with several root elements switch without animation.")),this.animation==="slide"&&!i?await this._slideTransition(t,e,s):this.animation==="fade"&&!i?await this._fadeTransition(t,e):(t._setHidden(!0),e._setHidden(!1),this._currentView=e),n||e?.onResume?.()}async _slideTransition(t,e,s){let n=this.domNode,i=s==="forward",a=n.getBoundingClientRect(),r=t.domNode.getBoundingClientRect(),f=r.left-a.left,c=r.top-a.top,p=r.width,d=n.offsetWidth-f,l=`position:absolute;left:${f}px;top:${c}px;width:${p}px`;n.style.cssText=`position:relative;overflow:hidden;height:${n.offsetHeight}px`,t.domNode.style.cssText=`${l};z-index:${i?1:2}`,t.domNode.hidden=!1,e.domNode.style.cssText=`${l};z-index:${i?2:1}`,e.domNode.hidden=!1;let w=i?e.domNode:t.domNode,J=i?`${d}px`:"0px",U=i?"0px":`${d}px`,E=w.animate([{transform:`translateX(${J})`},{transform:`translateX(${U})`}],{duration:300,easing:"ease",fill:"forwards"});await E.finished,E.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",n.style.cssText="",this._currentView=e}async _fadeTransition(t,e){let s=this.domNode,n=s.getBoundingClientRect(),i=t.domNode.getBoundingClientRect(),a=i.left-n.left,r=i.top-n.top,f=i.width,c=`position:absolute;left:${a}px;top:${r}px;width:${f}px`;s.style.cssText=`position:relative;overflow:hidden;height:${s.offsetHeight}px`,t.domNode.style.cssText=c,t.domNode.hidden=!1,e.domNode.style.cssText=c,e.domNode.hidden=!1;let p=t.domNode.animate([{opacity:1},{opacity:0}],{duration:450,easing:"ease",fill:"forwards"}),d=e.domNode.animate([{opacity:0},{opacity:1}],{duration:450,easing:"ease",fill:"forwards"});await Promise.all([p.finished,d.finished]),p.cancel(),d.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",s.style.cssText="",this._currentView=e}_switchTo(t){let e=t.split("?")[0],s=this.routes[e];s&&this._pauseCurrent(s);for(let[n,i]of Object.entries(this.routes)){let a=n===e;i._setHidden(!a),a&&(this._currentView=i,i?.onResume?.())}}back(){history.back()}canGoBack(){return this._idx>0}getParams(){let t=window.location.hash.split("?")[1];return t?Object.fromEntries(new URLSearchParams(t)):{}}async navigate(t,e){let[s,n]=t.replace(/^\//,"").split("?"),i=s,a=e??this.routes[i]?.constructor;if(!a){console.error(`JOPS Router: no class found for path "${t}"`);return}this._navStack.length===0&&(this._baseView=this._currentView);let r=new a;await r.loadLayout(),r.inflate(),this.domNode.append(...r._roots()),this._history("pushState",{jopsNav:!0,jopsIdx:++this._idx},"#"+i+(n?"?"+n:"")),this._pauseCurrent(r),await r.onLayout?.(),await r.onResume?.(),r.bindEvents?.(),this._navStack.push(r),this._animateTransition(this._currentView,r,"forward")}async replace(t,e){this._closeStack();let[s,n]=t.replace(/^\//,"").split("?"),i=s,a="#"+i+(n?"?"+n:"");if(this.routes[i])this._history("replaceState",{...history.state,jopsIdx:this._idx},a),this._switchTo(i);else if(e){let r=new e;await r.loadLayout(),r.inflate(),this.domNode.append(...r._roots()),this._pauseCurrent(r),await r.onLayout?.(),await r.onResume?.(),r.bindEvents?.(),this._currentView&&this._currentView._setHidden(!0),this._currentView=r,this._history("replaceState",{...history.state,jopsIdx:this._idx},a)}else console.error(`JOPS Router: no instance or class found for path "${t}"`)}},A=class{static{h(this,"Thread")}#t=null;run(t){}onMessage(t){}start(t){if(typeof Worker>"u"){console.warn("JOPS Thread: Web Workers are not supported in this environment.");return}let e=this.run.toString();!e.startsWith("function")&&!e.startsWith("(")&&!e.startsWith("async")&&(e="function "+e);let s=new Blob([`const run = ${e};`,"self.onmessage = (e) => { run(e.data); };"],{type:"application/javascript"}),n=URL.createObjectURL(s);this.#t=new Worker(n),URL.revokeObjectURL(n),this.#t.onmessage=i=>this.onMessage(i),this.#t.postMessage(t)}terminate(){if(!this.#t){console.warn("JOPS Thread: no active worker to terminate.");return}this.#t.terminate(),this.#t=null}};export{L as EventBus,y as Router,P as Store,A as Thread,R as View,G as layout,D as mount,z as raw,V as unmount};
@@ -0,0 +1,35 @@
1
+ export type JopsAppProps = {
2
+ /** URL of the JOPS app's entry module, e.g. "/quotes/App.js". */
3
+ app: string;
4
+ /** id for the root <div> (CSS, tests, debugging). Does not choose the mount target. */
5
+ id?: string;
6
+ /** className for the root <div>. */
7
+ className?: string;
8
+ /** jops-core library URL; also used for the import map. Defaults to "/lib/jops-core.min.js". */
9
+ lib?: string;
10
+ };
11
+ type JopsGlobal = {
12
+ mount(options: {
13
+ app: string;
14
+ root: HTMLElement;
15
+ }): Promise<unknown>;
16
+ /** Ends the app completely (Router, views, Store, EventBus, history hook). */
17
+ unmount(): void;
18
+ };
19
+ declare global {
20
+ interface Window {
21
+ JOPS?: JopsGlobal;
22
+ }
23
+ }
24
+ /**
25
+ * Hosts a JOPS SPA inside a Next.js page. Renders an empty <div> that becomes
26
+ * the JOPS root; React never renders children into it, so JOPS owns its DOM.
27
+ *
28
+ * @example
29
+ * import JopsApp from "jops-core/nextjs";
30
+ * export default function Page() {
31
+ * return <JopsApp app="/quotes/App.js" id="jops-quotes-root" />;
32
+ * }
33
+ */
34
+ export default function JopsApp({ app, id, className, lib }: JopsAppProps): import("react").JSX.Element;
35
+ export {};
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef } from "react";
4
+ const DEFAULT_LIB = "/lib/jops-core.min.js";
5
+ const SCRIPT_ID = "jops-core";
6
+ const IMPORT_MAP_ID = "jops-core-importmap";
7
+ function ensureImportMap(lib) {
8
+ if (document.getElementById(IMPORT_MAP_ID)) return;
9
+ const map = document.createElement("script");
10
+ map.type = "importmap";
11
+ map.id = IMPORT_MAP_ID;
12
+ map.textContent = JSON.stringify({ imports: { "jops-core": lib } });
13
+ document.head.appendChild(map);
14
+ }
15
+ function loadJops(lib) {
16
+ if (window.JOPS) return Promise.resolve(window.JOPS);
17
+ return new Promise((resolve, reject) => {
18
+ window.addEventListener("jops:ready", () => resolve(window.JOPS), { once: true });
19
+ if (!document.getElementById(SCRIPT_ID)) {
20
+ ensureImportMap(lib);
21
+ const script = document.createElement("script");
22
+ script.type = "module";
23
+ script.src = lib;
24
+ script.id = SCRIPT_ID;
25
+ script.dataset.host = "next";
26
+ script.onerror = () => reject(new Error(`JOPS: failed to load ${lib}`));
27
+ document.head.appendChild(script);
28
+ }
29
+ });
30
+ }
31
+ function JopsApp({ app, id, className, lib = DEFAULT_LIB }) {
32
+ const rootRef = useRef(null);
33
+ useEffect(() => {
34
+ let cancelled = false;
35
+ let jops = null;
36
+ loadJops(lib).then((loaded) => {
37
+ if (cancelled || !rootRef.current) return;
38
+ jops = loaded;
39
+ return loaded.mount({ app, root: rootRef.current });
40
+ }).catch((err) => console.error(err));
41
+ return () => {
42
+ cancelled = true;
43
+ jops?.unmount();
44
+ };
45
+ }, [app, lib]);
46
+ return /* @__PURE__ */ jsx("div", { ref: rootRef, id, className, "data-jops-root": "", style: { position: "relative" } });
47
+ }
48
+ export {
49
+ JopsApp as default
50
+ };
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "jops-core",
3
- "version": "1.0.14",
3
+ "version": "1.1.0",
4
4
  "description": "A no-bundler, OOP-first SPA framework with Android-style view composition",
5
5
  "type": "module",
6
- "exports": "./lib/jops-core.min.js",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./lib/jops-core.d.ts",
9
+ "default": "./lib/jops-core.min.js"
10
+ },
11
+ "./nextjs": {
12
+ "types": "./lib/nextjs/JopsApp.d.ts",
13
+ "default": "./lib/nextjs/JopsApp.js"
14
+ }
15
+ },
7
16
  "browser": "./lib/jops-core.min.js",
8
17
  "types": "./lib/jops-core.d.ts",
9
18
  "bin": {
@@ -12,11 +21,13 @@
12
21
  "files": [
13
22
  "lib/jops-core.min.js",
14
23
  "lib/jops-core.d.ts",
24
+ "lib/nextjs/",
15
25
  "bin/init.js",
16
26
  "templates/"
17
27
  ],
18
28
  "scripts": {
19
- "build": "esbuild src/jops-core.js --bundle --format=esm --minify --keep-names --outfile=lib/jops-core.min.js && tsc -p tsconfig.types.json && npm pack"
29
+ "build:nextjs": "esbuild jops-nextjs/JopsApp.tsx --format=esm --jsx=automatic --target=es2022 --outdir=lib/nextjs && tsc -p tsconfig.nextjs.json",
30
+ "build": "esbuild src/jops-core.js --bundle --format=esm --minify --keep-names --outfile=lib/jops-core.min.js && tsc -p tsconfig.types.json && npm run build:nextjs && npm pack"
20
31
  },
21
32
  "homepage": "https://cdn.ui-db.com",
22
33
  "repository": {
@@ -52,13 +63,30 @@
52
63
  "ios",
53
64
  "hybrid-app",
54
65
  "phonegap",
55
- "hybrid-mobile"
66
+ "hybrid-mobile",
67
+ "nextjs",
68
+ "next-js",
69
+ "next.js",
70
+ "ssr"
56
71
  ],
57
72
  "author": "arik@ui-db.com",
58
73
  "license": "MIT",
59
74
  "devDependencies": {
75
+ "@types/react": "^19.3.0",
60
76
  "esbuild": "^0.25.12",
61
77
  "typescript": "^7.0.2"
62
78
  },
63
- "packageManager": "npm@12.0.2+sha512.b885e890b9418fa1693544d05f53e64f9a73ec194837d4258b15fecdd692347b1dd2a517b1b0cbaf9d31cd8e92c3b70956bd2ecc72833a57b4b3098f5bfa7943"
79
+ "packageManager": "npm@12.0.2+sha512.b885e890b9418fa1693544d05f53e64f9a73ec194837d4258b15fecdd692347b1dd2a517b1b0cbaf9d31cd8e92c3b70956bd2ecc72833a57b4b3098f5bfa7943",
80
+ "peerDependencies": {
81
+ "react": ">=18",
82
+ "next": ">=15"
83
+ },
84
+ "peerDependenciesMeta": {
85
+ "react": {
86
+ "optional": true
87
+ },
88
+ "next": {
89
+ "optional": true
90
+ }
91
+ }
64
92
  }
@@ -0,0 +1,34 @@
1
+ # Next.js + JOPS embedded SPA
2
+
3
+ A Next.js (App Router) project that hosts a [JOPS](https://github.com/uidb-dev/jops-core) single-page app on one of its pages. Created by `npx jops-core init` (template 3) in the current folder: in an empty folder, or merged into a fresh npm project (an existing `package.json` keeps its entries, an installed `jops-core` included).
4
+
5
+ ```bash
6
+ npm run dev
7
+ ```
8
+
9
+ Open http://localhost:3000/about and http://localhost:3000/jops-spa.
10
+
11
+ ## What's inside
12
+
13
+ | Path | What it is |
14
+ |---|---|
15
+ | `src/app/about/page.tsx` | A React-only page (no JOPS). |
16
+ | `src/app/jops-spa/page.tsx` | Renders `<JopsApp app="/jops-spa/App.js" />` from `jops-core/nextjs`, which launches the JOPS SPA. |
17
+ | `src/app/api/quotes/route.ts` | `GET /api/quotes[?status=…]`: the SPA's backend, reading `data/quotes.json`. |
18
+ | `public/jops-spa/` | The JOPS SPA: plain ES modules and HTML layouts, served as-is. A Router with `#login` (mock login, no request) and `#quotes` (quotes list with status filters). |
19
+ | `public/lib/jops-core.min.js` | The JOPS library, vendored. |
20
+
21
+ ## Conventions
22
+
23
+ - **Absolute paths** for everything JOPS loads (`/jops-spa/Quotes.js`, `/jops-spa/layout/quotes.html`).
24
+ - **Vendored library:** `public/lib/jops-core.min.js`, which must match the `lib` URL used by `JopsApp` (default `/lib/jops-core.min.js`). After updating the `jops-core` package, run `npm run jops:update` to copy the new version into `public/lib/`.
25
+ - **One stylesheet:** Tailwind is generated by Next.js and also covers the classes used in `public/jops-spa/`. `setStyle()` has no effect in an embedded JOPS app.
26
+ - **Lifecycle:** leaving `/jops-spa` (a link or Back) ends the JOPS app completely; returning launches it again. The mock login is kept in the JOPS `Store`, so it is cleared too.
27
+
28
+ ## Deploying
29
+
30
+ This is a standard Next.js app: `npm run build` produces `.next/` (not a static `dist/` folder), and it runs on a Node.js server because of the Route Handler (`/api/quotes`). Deploy it like any Next.js app; see the [Next.js deployment docs](https://nextjs.org/docs/app/getting-started/deploying). Three points specific to the embedded JOPS app:
31
+
32
+ - **Deploy `public/` with the app.** The whole JOPS SPA and the library live there. With `output: "standalone"`, Next.js leaves `public/` and `.next/static/` out of `.next/standalone/` on purpose; copy both in, or the JOPS app is missing.
33
+ - **Caching:** files in `public/` are not content-hashed, so a CDN or the browser can keep serving an old `/jops-spa/*.js` or `/lib/jops-core.min.js` after a deploy. Use short cache headers for those paths, or versioned paths.
34
+ - **Library version:** after updating the `jops-core` package, run `npm run jops:update` before `npm run build`, so `public/lib/jops-core.min.js` matches the installed version.