jops-core 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,8 @@ No Webpack. No Babel. No compiler. Just `<script type="module">` and a browser.
23
23
 
24
24
  **Next.js teams:** a JOPS SPA can also run inside a Next.js page, next to React, with Next.js as its backend. See [Next.js Integration](#nextjs-integration).
25
25
 
26
+ **WordPress sites:** a JOPS SPA can run inside a WordPress page (Elementor or the block editor) through the JOPS plugin and its `[jops]` shortcode. See [WordPress Integration](#wordpress-integration).
27
+
26
28
  ---
27
29
 
28
30
  ## JOPS Demos
@@ -54,7 +56,14 @@ The import map lets your app modules use `import { View } from "jops-core"`. The
54
56
 
55
57
  `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.
56
58
 
57
- Replace `@latest` with a specific version (e.g. `@1.0.4`) to pin. No build step, no npm, no bundler.
59
+ Replace `@latest` with a specific version (e.g. `@1.1.2`) to pin. No build step, no npm, no bundler.
60
+
61
+ By default the app renders into `<body>`. To render it into an element instead (keeping the rest of the page), add `data-root` with the element's id:
62
+
63
+ ```html
64
+ <div id="app"></div>
65
+ <script type="module" src="https://cdn.jsdelivr.net/npm/jops-core@latest/lib/jops-core.min.js" data-app="./src/App.js" data-root="app"></script>
66
+ ```
58
67
 
59
68
  > **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.
60
69
 
@@ -378,42 +387,130 @@ The root `<div>` has `position: relative`, which the Router's slide and fade ani
378
387
  - **Next.js owns the CSS.** `setStyle()` has no effect inside Next.js (a console message says so once). Tailwind CSS v4 also scans the files in `public/`, so Tailwind classes in JOPS layouts and views are generated by the Next.js build. Write class names as complete literal strings (`"bg-sky-50"`, never `` `bg-${color}-50` ``). Put other CSS in `globals.css` or a CSS file imported by the page, scoped under the app's root class (`.my-app table td { … }`), since it applies to the whole site.
379
388
  - **Backend calls:** `fetch("/api/…")` to Route Handlers on the same origin; the session cookie is sent automatically. A JOPS app can't call Server Actions.
380
389
  - **One JOPS app per page.** `Router`, `Store` and `EventBus` are page-wide singletons.
390
+ - **Always use `JopsApp` inside a Next.js page.** Don't add the jops-core `<script>` or an import map to a page or layout yourself: React doesn't run scripts it renders on soft navigation, and nothing would end the app when the page is left.
391
+
392
+ ### Deploying
393
+
394
+ The result is a standard Next.js app: `npm run build` produces `.next/`, and it runs on a Node.js server when it uses server features such as Route Handlers. Deploy it like any Next.js app (see the [Next.js deployment docs](https://nextjs.org/docs/app/getting-started/deploying)). Points specific to JOPS:
395
+
396
+ - **Deploy `public/` with the app**: the whole JOPS app and the library live there. With `output: "standalone"`, copy `public/` (and `.next/static/`) into `.next/standalone/` yourself.
397
+ - **Caching:** files in `public/` are not content-hashed, so a CDN or the browser may keep serving an old `jops-core.min.js` or view after a deploy. Use short cache headers for those paths, or versioned paths.
398
+ - **Library version:** run `npx jops-core update` after updating the package and before `npm run build`.
399
+
400
+ ---
401
+
402
+ ## WordPress Integration
403
+
404
+ A JOPS SPA can run on a WordPress site, either inside a page next to the theme and the page's other content (built with Elementor or the block editor), or as a separate app in its own folder. WordPress stays the CMS and the backend; the JOPS app's files are served as static files.
405
+
406
+ | | Embedded in a page | Independent SPA |
407
+ | --- | --- | --- |
408
+ | What it is | The app renders inside a WordPress page, below the theme's header and next to the page's content | A complete JOPS app with its own `index.html`, opened at `https://<site>/<folder>/` |
409
+ | How | The **JOPS plugin** and the `[jops]` shortcode, or an Elementor HTML widget | Upload the app folder; no plugin |
410
+ | Shares the theme, menu and page | Yes | No (separate document) |
411
+
412
+ ### Install the JOPS plugin
413
+
414
+ The plugin registers the `[jops]` shortcode and includes the jops-core library, so the page needs nothing else. Pick the way that matches your site:
415
+
416
+ | Your site | Install |
417
+ | --- | --- |
418
+ | **No npm** (hosting with wp-admin only, XAMPP, MAMP, LocalWP, …) | Download [`jops-wordpress.zip`](https://github.com/uidb-dev/jops-core/releases/latest/download/jops-wordpress.zip), then wp-admin → **Plugins → Add New Plugin → Upload Plugin** → choose the zip → **Install Now** → **Activate** |
419
+ | **npm project run by [`wp-env`](https://www.npmjs.com/package/@wordpress/env)** (a folder with `.wp-env.json`) | `npx jops-core init --wp` in that folder: it writes the plugin to `plugins/jops/` and adds `"./plugins/jops"` to `.wp-env.json`. `npx wp-env start` then installs and activates it |
420
+ | **npm project inside a WordPress folder** (a theme, a plugin, a Bedrock project, …) | `npx jops-core init --wp`: it searches this folder and the folders above it for WordPress (`wp-load.php` + `wp-content/`, or Bedrock's `web/wp/` + `web/app/`) and writes the plugin to its plugins folder. Use `--wp-root <path>` to point to WordPress yourself. Then activate the plugin in wp-admin (or `wp plugin activate jops`) |
421
+
422
+ The three ways install the same plugin into `wp-content/plugins/jops/`.
423
+
424
+ - **Updating:** without npm, upload the newer zip the same way and choose **Replace current with uploaded**. With npm, run `npm i jops-core@latest` and then `npx jops-core update`, which refreshes the plugin and its library.
425
+ - **Keep `package.json` out of the WordPress root:** a `node_modules/` folder there is served publicly. `init --wp` warns when it finds one.
426
+
427
+ ### Put the app in the WordPress root
428
+
429
+ 1. **Place the app folder in the WordPress root** (the folder with `wp-config.php`), e.g. `<root>/jops-spa/`. How it gets there is up to you: upload it, copy it with FTP or SSH, deploy it with your pipeline, or map it with a `"mappings"` entry in `.wp-env.json`. A real folder is served as static files: WordPress only handles URLs that are not existing files or folders.
430
+ 2. **Use root-based paths everywhere** JOPS loads something: `mount()`/`app`, `src=`, `layout=`, `loadLayout()`, stylesheets and images, e.g. `/jops-spa/src/App.js` and `loadLayout("/jops-spa/src/layout/main.html")`. If WordPress runs in a subfolder (`example.com/blog/`), include it: `/blog/jops-spa/...`. ES module `import` statements inside your `.js` files resolve relative to the file as usual.
431
+ 3. **Pick a folder name that is not a page slug** and doesn't start with `wp-`: the folder always wins over a WordPress page with the same address.
432
+
433
+ To start without npm, copy the [hello-world template](https://github.com/uidb-dev/jops-core/tree/main/templates/hello-world) (`src/` and `assets/`) into `<root>/jops-spa/` and change its paths to root-based ones: `/src/...` and `./src/...` become `/jops-spa/src/...`, and `/assets/...` becomes `/jops-spa/assets/...`.
434
+
435
+ ### Add the app to a page
436
+
437
+ Edit the page with Elementor, add a **Shortcode** widget (General → Shortcode) and enter:
438
+
439
+ ```text
440
+ [jops app="/jops-spa/src/App.js" id="jops-spa-root" class="jops-spa"]
441
+ ```
442
+
443
+ In the block editor, use the **Shortcode** block; any editor that runs shortcodes works the same way.
381
444
 
382
- ### Without React: hosted mode
445
+ | Attribute | Description |
446
+ | --- | --- |
447
+ | `app` | **Required.** Root-based path of the app's entry module (its default export is created) |
448
+ | `id` | `id` of the app's root `<div>` (default `jops-root`) |
449
+ | `class` | `class` of the root `<div>`; use it to scope the app's CSS |
450
+ | `lib` | URL of `jops-core.min.js` (default: the plugin's copy). Use one URL per page, or the browser loads two jops-core instances |
451
+
452
+ - **The app runs on the published page.** In the Elementor editor the widget shows a placeholder ("JOPS app … runs on the published page") and loads no scripts.
453
+ - **One JOPS app per page.** A second `[jops]` on the same page logs a warning and is not started.
454
+ - The shortcode outputs the import map, the library in hosted mode, the root `<div>` and a small script that calls `mount()` once the library has loaded (see [Embedding JOPS in Other Pages](#embedding-jops-in-other-pages-hosted-mode)).
383
455
 
384
- `JopsApp` uses hosted mode, which any host page can use directly. Load the library with `data-host` and no `data-app`: nothing starts automatically, and `window.JOPS` exposes `mount()` and `unmount()` (a `jops:ready` event fires when it is available):
456
+ **Without the plugin**, an Elementor **HTML** widget can hold the same code by hand. Vendor the library in the app folder (`/jops-spa/lib/jops-core.min.js`, copied from `node_modules/jops-core/lib/` or the [release files](https://github.com/uidb-dev/jops-core/tree/main/lib)):
385
457
 
386
458
  ```html
387
- <script type="importmap">{ "imports": { "jops-core": "/lib/jops-core.min.js" } }</script>
388
- <script type="module" src="/lib/jops-core.min.js" data-host></script>
389
- <div id="app" style="position: relative"></div>
459
+ <script type="importmap">
460
+ { "imports": { "jops-core": "/jops-spa/lib/jops-core.min.js" } }
461
+ </script>
462
+ <script type="module" src="/jops-spa/lib/jops-core.min.js" data-host></script>
463
+
464
+ <div id="jops-spa-root" class="jops-spa" style="position: relative"></div>
390
465
  <script type="module">
391
- const start = () => window.JOPS.mount({ app: "/my-app/App.js", root: document.getElementById("app") });
466
+ const start = () => window.JOPS.mount({ app: "/jops-spa/src/App.js", root: document.getElementById("jops-spa-root") });
392
467
  window.JOPS ? start() : window.addEventListener("jops:ready", start, { once: true });
393
468
  </script>
394
469
  ```
395
470
 
396
- | Function | Description |
397
- | --- | --- |
398
- | `mount({ app, root })` | Imports `app` and creates its default export; the root view renders into `root` (an `HTMLElement`) instead of `<body>`. Returns a promise of the App instance |
399
- | `unmount()` | Ends the app completely (see "App lifecycle" above). Safe to call more than once; `mount()` can start an app again afterwards |
471
+ Don't combine the shortcode and a hand-written import map on the same page.
472
+
473
+ ### Conventions
474
+
475
+ - **CSS:** `setStyle()` has no effect on an embedded app (the page owns the CSS). Link the app's stylesheet from its layout (`<link rel="stylesheet" href="/jops-spa/src/css/styles.css">`) or add it to the theme. It applies to the whole page, so scope every rule under the root class (`.jops-spa .card { … }`) and avoid global rules (`body`, `*`, `:root`, bare tag selectors).
476
+ - **Routing:** WordPress owns the path (`/my-page/`), JOPS owns the hash (`/my-page/#details?id=7`). Leaving the page is a full page load, which ends the app.
477
+ - **Backend:** call the WordPress REST API with `fetch("/wp-json/…")`. Requests are same-origin, so the login cookie is sent; requests that need the logged-in user (and every write) also need a REST nonce (`X-WP-Nonce` header, created with `wp_create_nonce("wp_rest")`), which your own theme or plugin code must provide to the app.
478
+ - **Caching:** the app's files are static, and caching plugins, CDNs and browsers may keep serving old copies after an update. Use versioned paths or short cache headers for the app folder, and hard-reload while developing.
479
+
480
+ ### Independent SPA
481
+
482
+ A complete JOPS app with its own `index.html` (as created by `npx jops-core init`) also runs from a folder in the WordPress root: upload it as `<root>/jops-spa/` and open `https://<site>/jops-spa/`. WordPress is not involved. With the library from the CDN, the app's `./` paths work as they are; with a vendored library, use root-based paths (`/jops-spa/src/App.js`, `/jops-spa/lib/jops-core.min.js` in the import map and the script tag).
483
+
484
+ ---
485
+
486
+ ## Embedding JOPS in Other Pages (Hosted Mode)
400
487
 
401
- Both are also exported by the module (`import { mount, unmount } from "jops-core"`) for modules running on the same page. In hosted mode the Android back button integration is off.
488
+ A JOPS app can run inside any page that is not a React component: a plain HTML page, or a page rendered by another server framework (PHP, ASP.NET, Rails, Django, …). This is the mechanism `JopsApp` uses in Next.js and the JOPS plugin uses in WordPress; **inside a Next.js page, use `JopsApp`** instead (see [Next.js Integration](#nextjs-integration)), and on a WordPress site the `[jops]` shortcode (see [WordPress Integration](#wordpress-integration)).
402
489
 
403
- A **standalone** app (started by `data-app`) can render into an element instead of `<body>` with `data-root`:
490
+ Load the library with `data-host` and no `data-app`: nothing starts automatically, and `window.JOPS` exposes `mount()` and `unmount()` (a `jops:ready` event fires once jops-core has fully loaded, so a listener may call `mount()` directly). The import map and the script `src` must be the same URL, so the page has a single jops-core instance.
404
491
 
405
492
  ```html
406
- <div id="app"></div>
407
- <script type="module" src="/lib/jops-core.min.js" data-app="./src/App.js" data-root="app"></script>
408
- ```
493
+ <script type="importmap">
494
+ { "imports": { "jops-core": "https://cdn.jsdelivr.net/npm/jops-core@latest/lib/jops-core.min.js" } }
495
+ </script>
496
+ <script type="module" src="https://cdn.jsdelivr.net/npm/jops-core@latest/lib/jops-core.min.js" data-host></script>
409
497
 
410
- ### Deploying
498
+ <div id="app" style="position: relative"></div>
499
+ <script type="module">
500
+ const start = () => window.JOPS.mount({ app: "./my-app/App.js", root: document.getElementById("app") });
501
+ window.JOPS ? start() : window.addEventListener("jops:ready", start, { once: true });
502
+ </script>
503
+ ```
411
504
 
412
- The result is a standard Next.js app: `npm run build` produces `.next/`, and it runs on a Node.js server when it uses server features such as Route Handlers. Deploy it like any Next.js app (see the [Next.js deployment docs](https://nextjs.org/docs/app/getting-started/deploying)). Points specific to JOPS:
505
+ | Function | Description |
506
+ | --- | --- |
507
+ | `mount({ app, root })` | Imports `app` and creates its default export; the root view renders into `root` (an `HTMLElement`) instead of `<body>`. Returns a promise of the App instance |
508
+ | `unmount()` | Ends the app completely: destroys the Router and every view, empties `root`, clears `Store` and `EventBus`, and restores the history methods JOPS wrapped. Safe to call more than once; `mount()` can start an app again afterwards |
413
509
 
414
- - **Deploy `public/` with the app**: the whole JOPS app and the library live there. With `output: "standalone"`, copy `public/` (and `.next/static/`) into `.next/standalone/` yourself.
415
- - **Caching:** files in `public/` are not content-hashed, so a CDN or the browser may keep serving an old `jops-core.min.js` or view after a deploy. Use short cache headers for those paths, or versioned paths.
416
- - **Library version:** run `npx jops-core update` after updating the package and before `npm run build`.
510
+ - **Paths depend on where the library comes from.** With the library from the CDN (as above), JOPS resolves paths relative to the **hosting page**: `./my-app/App.js` on `example.com/admin/users` means `example.com/admin/my-app/App.js`, and root-absolute `/my-app/...` paths are turned page-relative too. With a vendored library on the same origin (`/lib/jops-core.min.js` in both the import map and `src`), write root-absolute paths (`/my-app/App.js`, `src="/my-app/Users.js"`), which work on every page.
511
+ - **Ending the app:** a full page load ends it anyway. A host that removes the app's element without a page load, or navigates with `history.pushState()`, calls `unmount()`; when history moves to another path the Router also ends the app by itself.
512
+ - `mount` and `unmount` are also exported by the module (`import { mount, unmount } from "jops-core"`) for modules running on the same page.
513
+ - In hosted mode `setStyle()` has no effect (the host page provides the CSS) and the Android back button integration is off.
417
514
 
418
515
  ---
419
516
 
@@ -482,6 +579,7 @@ Optional hooks, called by the Router:
482
579
  | `addSubView(containerId, view)` | Appends `view` into the child element with `id="containerId"`, runs its full lifecycle |
483
580
  | `insertSubView(view, index)` | Inserts `view` at position `index` within `this.domNode`, runs its full lifecycle |
484
581
  | `removeSubView(view)` | Removes `view` from DOM and `this.subViews`, calls `view.destroy()` |
582
+ | `removeSubViews(condition?)` | Removes every subview with `removeSubView()`, or only those matching `condition` (the others keep their order). Direct subviews only; not available on a `Router` (logs a warning) |
485
583
  | `show()` | Shows the view again (removes the inline `display` override) |
486
584
  | `hide()` | Hides the view (`display: none`) |
487
585
  | `destroy()` | Recursively destroys subtree — calls `destroy()` on all subviews, removes the view's elements from the DOM, removes its owned `EventBus` subscriptions, nulls references |
@@ -569,7 +667,7 @@ Returned by `view.get(id)` (and, as an array, by `view.find(id)`). Wraps a singl
569
667
 
570
668
  `set()`, `attr()` and `value()` never throw. When no element has the id, they do nothing (`value()` returns `""`) and log a warning with the id — usually a typo. On a destroyed view (for example after an async request finished when the user had already left), they do nothing silently. Code that uses `element` or `this.domNode` directly has no such guard.
571
669
 
572
- > `set()` replaces plain markup. Don't use it to clear a container that holds sub-views: call `removeSubView()` for each of them first, otherwise they stay in the parent's `subViews`.
670
+ > `set()` replaces plain markup. Don't use it to clear a container that holds sub-views: call `removeSubViews()` (or `removeSubView()` for each of them) first, otherwise they stay in the parent's `subViews`.
573
671
 
574
672
  ```js
575
673
  this.get("username").set("Alice");
@@ -819,7 +917,7 @@ Prefer the owned form inside views. A global subscription made by a view keeps t
819
917
 
820
918
  ### `mount()` / `unmount()` — Functions
821
919
 
822
- Start and end an app in a host page (hosted mode). Used by `JopsApp` in Next.js; see [Without React: hosted mode](#without-react-hosted-mode). The page must load jops-core with `data-host` (otherwise it starts `data-app` / `./src/App.js` by itself); `window.JOPS.mount` / `window.JOPS.unmount` are the same functions.
920
+ Start and end an app in a host page (hosted mode). Used by `JopsApp` in Next.js and by the JOPS WordPress plugin; see [Embedding JOPS in Other Pages](#embedding-jops-in-other-pages-hosted-mode). The page must load jops-core with `data-host` (otherwise it starts `data-app` / `./src/App.js` by itself); `window.JOPS.mount` / `window.JOPS.unmount` are the same functions.
823
921
 
824
922
  ```js
825
923
  import { mount, unmount } from "jops-core";
@@ -871,10 +969,11 @@ t.terminate();
871
969
  | Command | Description |
872
970
  | ---------------------- | -------------------------------------------------------------------------- |
873
971
  | `npx jops-core init` | Vendor library, configure `index.html`, inject scripts into `package.json`. Detects Cordova and Capacitor projects (see [Hybrid Mobile Apps](#hybrid-mobile-apps-cordova-ionic--capacitor)). Template 3 creates a Next.js project instead (see [Next.js Integration](#nextjs-integration)) |
874
- | `npx jops-core update` | Copy the latest `jops-core.min.js` from `node_modules` to `lib/` (`public/lib/` in a Next.js project) |
972
+ | `npx jops-core init --wp` | Install the JOPS WordPress plugin: into `plugins/jops/` of a `wp-env` project, otherwise into the plugins folder of the WordPress installation found in this folder or above it (see [WordPress Integration](#wordpress-integration)) |
973
+ | `npx jops-core update` | Copy the latest `jops-core.min.js` from `node_modules` to `lib/` (`public/lib/` in a Next.js project); also refreshes an installed JOPS WordPress plugin |
875
974
  | `npx jops-core build` | Generate production-ready `dist/` folder (skipped in Cordova/Capacitor projects) |
876
975
 
877
- `init` and `update` accept `--cordova`, `--capacitor` or `--web` to override the detected project type.
976
+ `init` and `update` accept `--cordova`, `--capacitor` or `--web` to override the detected project type, and `--wp-root <path>` to point to a WordPress folder instead of searching for it (implies `--wp`).
878
977
 
879
978
  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.
880
979
 
package/bin/init.js CHANGED
@@ -11,11 +11,16 @@ const [,, command, ...flags] = process.argv;
11
11
  if (command !== "init" && command !== "build" && command !== "update") {
12
12
  console.log("Usage:");
13
13
  console.log(" npx jops-core init — vendor library and configure index.html, or create a template project");
14
- console.log(" npx jops-core update — copy the latest jops-core.min.js to lib/ (public/lib/ in a Next.js project)");
14
+ console.log(" npx jops-core init --wp — install the JOPS WordPress plugin: into plugins/jops/ of a wp-env project");
15
+ console.log(" (folder with .wp-env.json), otherwise into the plugins folder of the");
16
+ console.log(" WordPress installation found in this folder or above it");
17
+ console.log(" npx jops-core update — copy the latest jops-core.min.js to lib/ (public/lib/ in a Next.js project);");
18
+ console.log(" also refreshes an installed JOPS WordPress plugin (found as for init --wp)");
15
19
  console.log(" npx jops-core build — generate production-ready dist/ folder");
16
20
  console.log("");
17
21
  console.log("Options (init, update):");
18
22
  console.log(" --cordova | --capacitor | --web override the detected project type");
23
+ console.log(" --wp-root <path> WordPress folder to use instead of searching (implies --wp)");
19
24
  console.log("Options (all commands):");
20
25
  console.log(" --no-update-check skip the check for a newer jops-core version");
21
26
  process.exit(0);
@@ -149,6 +154,73 @@ function isNextProject(dir) {
149
154
  return ["next.config.ts", "next.config.js", "next.config.mjs"].some((f) => fs.existsSync(path.join(dir, f)));
150
155
  }
151
156
 
157
+ // ─── WordPress ───────────────────────────────────────────────────────────────
158
+ // wp-env project (.wp-env.json here): the plugin is written to
159
+ // <project>/plugins/jops/ and listed in .wp-env.json, which mounts it into
160
+ // WordPress. Any other project: the plugin is written to the plugins folder of
161
+ // the WordPress installation found in this folder or above it (or --wp-root).
162
+
163
+ const wpRootIndex = flags.findIndex((f) => f === "--wp-root" || f.startsWith("--wp-root="));
164
+ const wpRootFlag = wpRootIndex < 0 ? null
165
+ : flags[wpRootIndex].startsWith("--wp-root=") ? flags[wpRootIndex].slice("--wp-root=".length)
166
+ : flags[wpRootIndex + 1];
167
+ if (wpRootIndex >= 0 && (!wpRootFlag || wpRootFlag.startsWith("--"))) {
168
+ console.error("[jops-core] --wp-root needs a path, e.g. --wp-root ../wordpress");
169
+ process.exit(1);
170
+ }
171
+ const wpMode = flags.includes("--wp") || wpRootIndex >= 0;
172
+ const wpEnvConfig = path.join(process.cwd(), ".wp-env.json");
173
+ const wpPluginDir = path.join(process.cwd(), "plugins", "jops");
174
+ const isWpEnv = !wpRootFlag && fs.existsSync(wpEnvConfig);
175
+ const relCwd = (p) => path.relative(process.cwd(), p) || ".";
176
+
177
+ // A WordPress installation at dir: { root, plugins, layout }, otherwise null.
178
+ // root is the folder served at the site root (where the JOPS app folder goes).
179
+ // wp-config.php is not checked: WordPress allows it one level above the root.
180
+ function wpInstallAt(dir) {
181
+ const has = (...p) => fs.existsSync(path.join(dir, ...p));
182
+ if (has("wp-load.php") && has("wp-content")) {
183
+ return { root: dir, plugins: path.join(dir, "wp-content", "plugins"), layout: "standard" };
184
+ }
185
+ if (has("web", "wp", "wp-load.php") && has("web", "app")) {
186
+ return { root: path.join(dir, "web"), plugins: path.join(dir, "web", "app", "plugins"), layout: "Bedrock" };
187
+ }
188
+ return null;
189
+ }
190
+
191
+ // --wp-root, or the nearest installation in this folder or above it.
192
+ function findWordPress() {
193
+ if (wpRootFlag) return wpInstallAt(path.resolve(wpRootFlag));
194
+ for (let dir = process.cwd(); ; dir = path.dirname(dir)) {
195
+ const found = wpInstallAt(dir);
196
+ if (found || dir === path.dirname(dir)) return found;
197
+ }
198
+ }
199
+
200
+ // Copies the plugin template (version filled in) and the library into dest,
201
+ // replacing earlier copies.
202
+ function installWpPlugin(dest) {
203
+ const src = path.join(__dirname, "../templates/wordpress/jops");
204
+ fs.mkdirSync(path.join(dest, "lib"), { recursive: true });
205
+ for (const name of fs.readdirSync(src)) {
206
+ const text = fs.readFileSync(path.join(src, name), "utf8");
207
+ fs.writeFileSync(path.join(dest, name), text.replaceAll("{{VERSION}}", pkg.version), "utf8");
208
+ }
209
+ fs.copyFileSync(path.join(__dirname, "../lib/jops-core.min.js"), path.join(dest, "lib", "jops-core.min.js"));
210
+ }
211
+
212
+ // Adds "./plugins/jops" to the "plugins" list of .wp-env.json, unless the
213
+ // folder is already listed. Returns true when the file was changed.
214
+ function addWpEnvPlugin(configPath, pluginDir) {
215
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
216
+ const base = path.dirname(configPath);
217
+ const plugins = config.plugins ?? [];
218
+ if (plugins.some((p) => typeof p === "string" && path.resolve(base, p) === pluginDir)) return false;
219
+ config.plugins = [...plugins, "./" + path.relative(base, pluginDir).split(path.sep).join("/")];
220
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
221
+ return true;
222
+ }
223
+
152
224
  // Copies a template project into dest (README.md included, unlike copyDir()).
153
225
  // package.json is merged separately; existing files are never overwritten.
154
226
  // npm drops .gitignore files from published packages, so the template stores
@@ -225,7 +297,65 @@ async function askTemplate() {
225
297
  return answer.trim();
226
298
  }
227
299
 
228
- const choice = command === "init" ? await askTemplate() : null;
300
+ const choice = command === "init" && !wpMode ? await askTemplate() : null;
301
+
302
+ // ─── init --wp: JOPS WordPress plugin (wp-env project) ───────────────────────
303
+
304
+ if (command === "init" && wpMode && isWpEnv) {
305
+ if (fs.existsSync(wpPluginDir)) {
306
+ console.error(`[jops-core] ${path.relative(process.cwd(), wpPluginDir)}/ already exists. Nothing was changed.`);
307
+ console.error(" Run `npx jops-core update` to refresh the plugin and the library.");
308
+ process.exitCode = 1;
309
+ } else {
310
+ installWpPlugin(wpPluginDir);
311
+ console.log(`[jops-core] Created the JOPS WordPress plugin v${pkg.version} → plugins/jops/ (jops.php, readme.txt, lib/jops-core.min.js)`);
312
+ if (addWpEnvPlugin(wpEnvConfig, wpPluginDir)) {
313
+ console.log('[jops-core] Added "./plugins/jops" to "plugins" in .wp-env.json');
314
+ } else {
315
+ console.log("[jops-core] .wp-env.json already lists plugins/jops");
316
+ }
317
+ console.log("");
318
+ console.log("[jops-core] Next steps:");
319
+ console.log(" 1. npx wp-env start (applies .wp-env.json and activates the plugin; the database is kept)");
320
+ console.log(" 2. Put your JOPS app folder in the WordPress root (e.g. a \"mappings\" entry in .wp-env.json)");
321
+ console.log(' 3. Add a Shortcode widget to the page: [jops app="/jops-spa/src/App.js" id="jops-spa-root"]');
322
+ }
323
+ }
324
+
325
+ // ─── init --wp: JOPS WordPress plugin (WordPress installation) ───────────────
326
+
327
+ if (command === "init" && wpMode && !isWpEnv) {
328
+ const wp = findWordPress();
329
+ const pluginDir = wp && path.join(wp.plugins, "jops");
330
+ if (!wp) {
331
+ console.error(wpRootFlag
332
+ ? `[jops-core] No WordPress installation at ${wpRootFlag} (looked for wp-load.php + wp-content/, or Bedrock's web/wp/ + web/app/).`
333
+ : "[jops-core] No WordPress installation found in this folder or above it (looked for wp-load.php + wp-content/,");
334
+ if (!wpRootFlag) console.error(" or Bedrock's web/wp/ + web/app/), and no .wp-env.json here.");
335
+ console.error(" Point to it with --wp-root <path>, or install the JOPS plugin zip in wp-admin.");
336
+ console.error(" A custom content folder (WP_CONTENT_DIR) other than Bedrock's is not supported.");
337
+ process.exitCode = 1;
338
+ } else if (fs.existsSync(pluginDir)) {
339
+ console.log(`[jops-core] WordPress (${wp.layout}) found at ${relCwd(wp.root)}`);
340
+ console.error(`[jops-core] ${relCwd(pluginDir)}/ already exists. Nothing was changed.`);
341
+ console.error(" Run `npx jops-core update` to refresh the plugin and the library.");
342
+ process.exitCode = 1;
343
+ } else {
344
+ console.log(`[jops-core] WordPress (${wp.layout}) found at ${relCwd(wp.root)} → plugins: ${relCwd(wp.plugins)}/`);
345
+ installWpPlugin(pluginDir);
346
+ console.log(`[jops-core] Created the JOPS WordPress plugin v${pkg.version} → ${relCwd(pluginDir)}/ (jops.php, readme.txt, lib/jops-core.min.js)`);
347
+ if (fs.existsSync(path.join(wp.root, "package.json"))) {
348
+ console.warn("[jops-core] Warning: package.json is in the WordPress root, so its node_modules/ is served publicly.");
349
+ console.warn(" Keep npm projects in a theme or plugin folder, or block node_modules/ on the web server.");
350
+ }
351
+ console.log("");
352
+ console.log("[jops-core] Next steps:");
353
+ console.log(" 1. Activate the plugin: wp-admin > Plugins > JOPS > Activate (or: wp plugin activate jops)");
354
+ console.log(` 2. Put your JOPS app folder in ${relCwd(wp.root)}/ (the WordPress root), e.g. ${relCwd(path.join(wp.root, "jops-spa"))}/`);
355
+ console.log(' 3. Add a Shortcode widget to the page: [jops app="/jops-spa/src/App.js" id="jops-spa-root"]');
356
+ console.log(" 4. Deploy the plugin folder like any other plugin");
357
+ }
358
+ }
229
359
 
230
360
  // ─── init: Next.js JOPS Embedded SPA (template 3) ────────────────────────────
231
361
 
@@ -370,7 +500,7 @@ function schemeWarnings(xml) {
370
500
 
371
501
  // ─── init ────────────────────────────────────────────────────────────────────
372
502
 
373
- if (command === "init" && choice !== "3") {
503
+ if (command === "init" && !wpMode && choice !== "3") {
374
504
  fs.mkdirSync(webRoot, { recursive: true });
375
505
  if (isHybrid) {
376
506
  console.log(`[jops-core] ${forced ? "Using" : "Detected"} ${type} project → web folder: ${rel(webRoot)}/`);
@@ -572,6 +702,22 @@ ${cordovaBody}${MODULE_SCRIPT}
572
702
 
573
703
  // ─── update ──────────────────────────────────────────────────────────────────
574
704
 
705
+ // An installed JOPS WordPress plugin (found as for init --wp): refresh the
706
+ // plugin and its library. A project's own lib/ is refreshed as well below.
707
+ let wpUpdated = false;
708
+ if (command === "update") {
709
+ const wp = isWpEnv ? null : findWordPress();
710
+ const pluginDir = isWpEnv ? wpPluginDir : wp && path.join(wp.plugins, "jops");
711
+ if (pluginDir && fs.existsSync(path.join(pluginDir, "jops.php"))) {
712
+ installWpPlugin(pluginDir);
713
+ console.log(`[jops-core] Updated the JOPS WordPress plugin in ${relCwd(pluginDir)}/ to v${pkg.version}`);
714
+ wpUpdated = true;
715
+ } else if (wpRootFlag) {
716
+ console.error(`[jops-core] No JOPS plugin found at ${wpRootFlag}. Run 'npx jops-core init --wp' first.`);
717
+ process.exit(1);
718
+ }
719
+ }
720
+
575
721
  if (command === "update") {
576
722
  // A Next.js project serves the library from public/lib/.
577
723
  const libRoot = type === "web" && isNextProject(projectRoot) ? path.join(projectRoot, "public") : webRoot;
@@ -580,13 +726,14 @@ if (command === "update") {
580
726
  const vendorSrc = path.join(__dirname, "../lib/jops-core.min.js");
581
727
 
582
728
  if (!fs.existsSync(libDir)) {
583
- console.error(`[jops-core] ${rel(libDir)}/ folder not found. Run 'npx jops-core init' first.`);
584
- process.exit(1);
729
+ if (!wpUpdated) {
730
+ console.error(`[jops-core] ${rel(libDir)}/ folder not found. Run 'npx jops-core init' first.`);
731
+ process.exit(1);
732
+ }
733
+ } else {
734
+ fs.copyFileSync(vendorSrc, vendorDest);
735
+ console.log(`[jops-core] Updated ${rel(vendorDest)} to v${pkg.version}`);
585
736
  }
586
-
587
- fs.copyFileSync(vendorSrc, vendorDest);
588
-
589
- console.log(`[jops-core] Updated ${rel(vendorDest)} to v${pkg.version}`);
590
737
  }
591
738
 
592
739
  // ─── build ───────────────────────────────────────────────────────────────────
@@ -274,6 +274,15 @@ export declare class View {
274
274
  * @param {View} view - The subview to remove.
275
275
  */
276
276
  removeSubView(view: View): void;
277
+ /**
278
+ * Removes subviews and destroys them, each through `removeSubView()`.
279
+ * With a condition, removes only the subviews it matches (the others keep
280
+ * their place and order).
281
+ * @param {(view: View) => boolean} [condition] - Which subviews to remove; all when omitted.
282
+ * @example
283
+ * this.removeSubViews((v) => v instanceof QuoteRow);
284
+ */
285
+ removeSubViews(condition?: (view: View) => boolean): void;
277
286
  /**
278
287
  * Inserts `view` at `index` within `this.domNode` and runs its full lifecycle.
279
288
  * A removed (destroyed) view is final: create a new instance to show it again.
@@ -486,6 +495,12 @@ export declare class Router extends View {
486
495
  * keeping the existing history state, and shows that route.
487
496
  */
488
497
  _switchToDefault(): void;
498
+ /**
499
+ * Not supported: a Router's subviews are its routes, and removing them
500
+ * would leave its route map pointing at destroyed views.
501
+ * @param {(view: View) => boolean} [condition]
502
+ */
503
+ removeSubViews(condition?: (view: View) => boolean): void;
489
504
  /**
490
505
  * Removes the history listeners, destroys the routes and stacked views and
491
506
  * clears the singleton. Called by `unmount()`; safe to call more than once.
@@ -1 +1 @@
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};
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,L=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,k=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,V=!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 V||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 T(){m+=1,y.singleton()?.destroy(),k.forEach(o=>o.destroy()),k.clear(),u&&(u.innerHTML=""),u=null,P.singleton().clear(),v.clear(),q()}h(T,"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(L.has(e))this.layout=L.get(e);else{let s=await fetch(e);if(!s.ok)throw new Error(`HTTP ${s.status}`);let n=await s.text();L.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,v.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)}removeSubViews(t){for(let e of[...this.subViews])(!t||t(e))&&this.removeSubView(e)}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,k.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))}},v=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&&T();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)}removeSubViews(t){console.warn("JOPS: removeSubViews() is not supported on a Router")}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){V=!0;try{history[t](e,"",s)}finally{V=!1}}_onHostNavigation(){if(this._destroyed||!this.routes)return;if(location.pathname!==this._pathname){T();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}};(()=>{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:T},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()})();export{v as EventBus,y as Router,P as Store,A as Thread,R as View,G as layout,D as mount,z as raw,T as unmount};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jops-core",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "A no-bundler, OOP-first SPA framework with Android-style view composition",
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "scripts": {
29
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"
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 && node scripts/build-wp-plugin.mjs && npm pack"
31
31
  },
32
32
  "homepage": "https://cdn.ui-db.com",
33
33
  "repository": {
@@ -67,7 +67,8 @@
67
67
  "nextjs",
68
68
  "next-js",
69
69
  "next.js",
70
- "ssr"
70
+ "ssr",
71
+ "wordpress"
71
72
  ],
72
73
  "author": "arik@ui-db.com",
73
74
  "license": "MIT",
@@ -0,0 +1,122 @@
1
+ <?php
2
+ /**
3
+ * Plugin Name: JOPS
4
+ * Plugin URI: https://github.com/uidb-dev/jops-core
5
+ * Description: Embeds a JOPS single-page app in a page with the [jops] shortcode, e.g. in Elementor's Shortcode widget: [jops app="/jops-spa/src/App.js" id="jops-spa-root"]
6
+ * Version: {{VERSION}}
7
+ * Requires at least: 6.5
8
+ * Requires PHP: 7.4
9
+ * Author: UIDB
10
+ * Author URI: https://cdn.ui-db.com
11
+ * License: MIT
12
+ * License URI: https://opensource.org/licenses/MIT
13
+ * Text Domain: jops
14
+ */
15
+
16
+ if ( ! defined( 'ABSPATH' ) ) {
17
+ exit;
18
+ }
19
+
20
+ /**
21
+ * [jops app="/jops-spa/src/App.js" id="jops-spa-root" class="" lib=""]
22
+ *
23
+ * Outputs the same block as the Elementor HTML-widget snippet: the "jops-core"
24
+ * import map, the library (hosted mode, data-host), the app's root <div> and an
25
+ * inline module that mounts the app. Module scripts run in document order after
26
+ * the page is parsed, so the library has fully loaded when the mount script
27
+ * runs. The mount script still waits for import(lib), which returns the module
28
+ * the library tag loads once it has fully run (or fails if it cannot load), so
29
+ * it also works when something inserts the block with JavaScript (Elementor
30
+ * popups, AJAX content) or replays its scripts later (script-delay plugins). One app per page: a second [jops] logs a warning and is not
31
+ * started. In the Elementor editor the shortcode outputs only a placeholder:
32
+ * the app runs on the published page.
33
+ *
34
+ * - app: root-based path of the app's entry module (required).
35
+ * - id: id of the root <div> (default "jops-root").
36
+ * - class: class of the root <div>, e.g. to scope the app's CSS.
37
+ * - lib: URL of jops-core.min.js (default: the copy in this plugin's lib/).
38
+ * Must be the same URL on the whole page, or the browser loads two
39
+ * jops-core instances.
40
+ *
41
+ * @param array|string $atts Shortcode attributes.
42
+ * @return string
43
+ */
44
+ function jops_shortcode( $atts ) {
45
+ $atts = shortcode_atts(
46
+ array(
47
+ 'app' => '',
48
+ 'id' => 'jops-root',
49
+ 'class' => '',
50
+ 'lib' => '',
51
+ ),
52
+ $atts,
53
+ 'jops'
54
+ );
55
+
56
+ $app = trim( $atts['app'] );
57
+ if ( '' === $app || '/' !== $app[0] ) {
58
+ return "\n<!-- JOPS: the app attribute must be a root-based path, e.g. [jops app=\"/jops-spa/src/App.js\"] -->\n";
59
+ }
60
+
61
+ $lib = '' !== trim( $atts['lib'] ) ? esc_url_raw( trim( $atts['lib'] ) ) : plugins_url( 'lib/jops-core.min.js', __FILE__ );
62
+ $config = array(
63
+ 'app' => $app,
64
+ 'root' => $atts['id'],
65
+ 'lib' => $lib,
66
+ );
67
+
68
+ if ( jops_in_elementor_editor() ) {
69
+ return sprintf(
70
+ '<div style="padding: 1em; border: 1px dashed currentColor; opacity: 0.7">JOPS app %s (runs on the published page)</div>',
71
+ esc_html( $app )
72
+ );
73
+ }
74
+
75
+ $json = JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES;
76
+ $map = wp_json_encode( array( 'imports' => array( 'jops-core' => $lib ) ), $json );
77
+ $div = sprintf(
78
+ '<div id="%s"%s data-jops-root style="position: relative"></div>',
79
+ esc_attr( $atts['id'] ),
80
+ '' !== $atts['class'] ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''
81
+ );
82
+
83
+ return '
84
+ <script type="importmap">' . $map . '</script>
85
+ <script type="module" src="' . esc_url( $lib ) . '" data-host="wordpress"></script>
86
+ ' . $div . '
87
+ <script type="module">
88
+ const c = ' . wp_json_encode( $config, $json ) . ';
89
+ const root = document.getElementById(c.root);
90
+ const fail = () => console.error("JOPS: could not load " + c.lib + "; #" + c.root + " was not started.");
91
+ const start = () => {
92
+ if (!window.JOPS) return fail();
93
+ if (document.querySelector("[data-jops-mounted]")) {
94
+ console.warn("JOPS: one app per page; #" + c.root + " was not started.");
95
+ return;
96
+ }
97
+ root.setAttribute("data-jops-mounted", "");
98
+ window.JOPS.mount({ app: c.app, root });
99
+ };
100
+ // Only wait for the library once its tag is in the page: import() alone would
101
+ // load jops-core without data-host (standalone mode).
102
+ if (document.querySelector("script[data-host]")) import(c.lib).then(start, fail);
103
+ else fail();
104
+ </script>
105
+ ';
106
+ }
107
+ add_shortcode( 'jops', 'jops_shortcode' );
108
+
109
+ /**
110
+ * Whether the shortcode is rendered by the Elementor editor: a widget rendered
111
+ * while editing, or the editor's preview iframe.
112
+ *
113
+ * @return bool
114
+ */
115
+ function jops_in_elementor_editor() {
116
+ if ( ! did_action( 'elementor/loaded' ) || ! class_exists( '\\Elementor\\Plugin' ) ) {
117
+ return false;
118
+ }
119
+ $elementor = \Elementor\Plugin::$instance;
120
+ return ( isset( $elementor->editor ) && $elementor->editor->is_edit_mode() )
121
+ || ( isset( $elementor->preview ) && $elementor->preview->is_preview_mode() );
122
+ }
@@ -0,0 +1,41 @@
1
+ === JOPS ===
2
+ Contributors: uidb
3
+ Tags: spa, javascript, elementor, shortcode, embed
4
+ Requires at least: 6.5
5
+ Requires PHP: 7.4
6
+ Stable tag: {{VERSION}}
7
+ License: MIT
8
+ License URI: https://opensource.org/licenses/MIT
9
+
10
+ Embeds a JOPS single-page app in a WordPress page with the [jops] shortcode.
11
+
12
+ == Description ==
13
+
14
+ JOPS (jops-core) is a no-bundler, class-based SPA framework. This plugin renders a JOPS app inside a WordPress page, next to the theme and the page's other content. It includes the jops-core library (lib/jops-core.min.js), so the page needs nothing else.
15
+
16
+ == Installation ==
17
+
18
+ Without npm: download jops-wordpress.zip (https://github.com/uidb-dev/jops-core/releases/latest/download/jops-wordpress.zip), then Plugins > Add New Plugin > Upload Plugin > choose the zip > Install Now. To update, upload the newer zip the same way and choose "Replace current with uploaded".
19
+ With npm: npx jops-core init --wp (installs the plugin into the WordPress project).
20
+
21
+ 1. Activate the plugin (Plugins > Installed Plugins > JOPS > Activate).
22
+ 2. Put your JOPS app folder in the WordPress root directory (next to wp-config.php), e.g. <root>/jops-spa/.
23
+ 3. Use root-based paths everywhere in the app (/jops-spa/src/..., layouts, images, stylesheets). If WordPress runs in a subfolder (example.com/blog/), include it: /blog/jops-spa/...
24
+ 4. Edit the page with Elementor, add a Shortcode widget (General > Shortcode) and enter:
25
+
26
+ [jops app="/jops-spa/src/App.js" id="jops-spa-root"]
27
+
28
+ Any editor that runs shortcodes works the same way (block editor: Shortcode block).
29
+
30
+ == Shortcode attributes ==
31
+
32
+ * app (required): root-based path of the app's entry module (its default export is the App class).
33
+ * id: id of the app's root element (default: jops-root).
34
+ * class: class of the app's root element; use it to scope the app's CSS.
35
+ * lib: URL of jops-core.min.js (default: this plugin's copy). Use one URL per page.
36
+
37
+ == Notes ==
38
+
39
+ * One JOPS app per page.
40
+ * Do not combine the shortcode with a hand-written JOPS import map on the same page: the browser would load two copies of jops-core.
41
+ * The app's CSS applies to the whole page: scope it under the root element's class.