jops-core 1.0.12 → 1.0.14

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
@@ -117,6 +117,166 @@ This gives you HTML syntax highlighting, Emmet, and autocomplete inside every ``
117
117
 
118
118
  ---
119
119
 
120
+ ## Hybrid Mobile Apps (Cordova, Ionic & Capacitor)
121
+
122
+ JOPS runs unchanged inside a mobile WebView, so the same app can ship to the iOS App Store and Google Play through a hybrid shell. Nothing on this page affects JOPS in a regular browser: every mobile adjustment is either feature-detected (it only runs when the native shell is present) or a CSS value that resolves to `0` outside it.
123
+
124
+ | Shell | Status |
125
+ | --- | --- |
126
+ | Apache Cordova (iOS + Android) | Supported and tested (cordova-ios 8, cordova-android 15) |
127
+ | Capacitor (iOS + Android) | Supported and tested (Capacitor 8) — install `@capacitor/app` for the Android back button |
128
+ | Ionic | Use Ionic's framework-free UI components (`@ionic/core`) on top of Cordova or Capacitor. `ionic start` always scaffolds an Angular, React or Vue app, which JOPS is not meant to be combined with — start from a plain Cordova or Capacitor project instead |
129
+
130
+ ### Setup order
131
+
132
+ **1. Create the hybrid project and its platforms.**
133
+
134
+ ```bash
135
+ # Cordova
136
+ cordova create my-app com.example.myapp MyApp
137
+ cd my-app
138
+ cordova platform add ios android
139
+ ```
140
+
141
+ ```bash
142
+ # Capacitor
143
+ mkdir my-app && cd my-app
144
+ npm init -y
145
+ npm i @capacitor/core @capacitor/ios @capacitor/android @capacitor/app
146
+ npm i -D @capacitor/cli
147
+ npx cap init MyApp com.example.myapp --web-dir www
148
+ npx cap add ios
149
+ npx cap add android
150
+ ```
151
+
152
+ **2. Install JOPS in the project root** — the folder with `package.json`, *not* inside `www/`. Cordova and Capacitor copy the whole web folder into the app, so a `node_modules/` there would be bundled too.
153
+
154
+ ```bash
155
+ npm i jops-core
156
+ ```
157
+
158
+ **3. Run `init` from the project root** (running it from inside `www/` / your `webDir` works too):
159
+
160
+ ```bash
161
+ npx jops-core init
162
+ ```
163
+
164
+ `init` detects the project type and writes the app into the web folder that the shell actually serves — `www/` for Cordova, the `webDir` from `capacitor.config.*` for Capacitor:
165
+
166
+ ```
167
+ [jops-core] Detected cordova project → web folder: www/
168
+ ```
169
+
170
+ **4. Build and run** with the shell's own tools: `cordova run ios` / `cordova run android`, or `npx cap sync` followed by `npx cap run ios` / `npx cap run android` (or open the native project in Xcode / Android Studio).
171
+
172
+ ### What `npx jops-core init` configures
173
+
174
+ | | Web | Cordova | Capacitor |
175
+ | --- | --- | --- | --- |
176
+ | `index.html`, `lib/`, template | current folder | `www/` | `webDir` |
177
+ | `.vscode/` and `package.json` scripts | current folder | project root | project root |
178
+ | `viewport-fit=cover` | — | ✓ | ✓ |
179
+ | Content-Security-Policy | — | adds `'unsafe-inline'` (inline import map) and `blob:` (`Thread` workers) to `script-src` | — (Capacitor has none by default) |
180
+ | `<script src="cordova.js">` | — | ✓ | — |
181
+ | iOS custom scheme in `config.xml` | — | ✓ (`app://localhost`) | — (Capacitor uses `capacitor://localhost`) |
182
+ | `"build"` script in `package.json` | ✓ | — (use `cordova build`) | — (use `npx cap sync`) |
183
+ | `"postinstall": "npx jops-core init"` | ✓ | ✓ | ✓ |
184
+
185
+ - Every step checks first, so running `init` again — including through `postinstall` — never duplicates anything.
186
+ - **Cordova's sample page:** when you pick the *Empty* or *Hello World* template and `www/index.html` is still the page created by `cordova create`, `init` renames it to `www/index.cordova.html` and writes a JOPS `index.html` instead (the sample's `js/index.js` expects markup that a JOPS app replaces). No files are deleted; `css/`, `img/` and `js/` from the sample can be removed by hand.
187
+ - **iOS scheme (Cordova):** JOPS loads `App.js`, views and layouts with native `import()` and `fetch()`, which WebViews block on `file://` pages. Cordova Android and Capacitor already serve from `https://localhost`; for Cordova iOS, `init` adds the following to `config.xml` (applied on the next `cordova prepare` / `build` / `run`):
188
+
189
+ ```xml
190
+ <platform name="ios">
191
+ <preference name="scheme" value="app" />
192
+ <preference name="hostname" value="localhost" />
193
+ </platform>
194
+ ```
195
+
196
+ Values you already set are never changed — any custom scheme works. `init` warns if the iOS scheme is `file`, `http` or `https`, or if `AndroidInsecureFileModeEnabled` is `true`, since both keep the app on `file://`. **Already-published apps:** the new origin cannot see data stored under `file://` (localStorage, IndexedDB), so plan a migration if your app stores data locally.
197
+ - `npx jops-core update` writes to the same web folder, and `npx jops-core build` is skipped in hybrid projects (the web folder is shipped as-is).
198
+ - Override the detection with `--cordova`, `--capacitor` or `--web` (for example `npx jops-core init --web` to treat the current folder as a plain web app).
199
+
200
+ For reference, the `index.html` that `init` produces for a new Cordova project:
201
+
202
+ ```html
203
+ <head>
204
+ <meta charset="UTF-8" />
205
+ <meta http-equiv="Content-Security-Policy"
206
+ content="default-src 'self' data: blob: https://ssl.gstatic.com; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' data: blob:; media-src * blob:; img-src 'self' data: blob: content:;" />
207
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
208
+ <script type="importmap">
209
+ { "imports": { "jops-core": "/lib/jops-core.min.js" } }
210
+ </script>
211
+ </head>
212
+ <body>
213
+ <script src="cordova.js"></script>
214
+ <script type="module" src="/lib/jops-core.min.js" data-app="./src/App.js"></script>
215
+ </body>
216
+ ```
217
+
218
+ ### Android back button
219
+
220
+ In Cordova and Capacitor, JOPS connects the Android back button (key and gesture) to the Router automatically:
221
+
222
+ - If `Router.singleton().canGoBack()` is `true`, it calls `Router.back()` — the current view receives `onBackPressed()` exactly as with browser back.
223
+ - On the first screen, it closes the app (`navigator.app.exitApp()`), the standard Android behaviour.
224
+
225
+ This is required on Android 16 (`targetSdk 36`, predictive back): without a `backbutton` listener, Cordova lets the system handle back and the app closes on the first press regardless of history. It also smooths over a cordova-android 15 issue where one key press fires `backbutton` twice.
226
+
227
+ The hook only activates on the `deviceready` event, which Cordova and Capacitor fire and a regular browser never does. If your app handles `backbutton` itself, turn the built-in handler off:
228
+
229
+ ```html
230
+ <script type="module" src="/lib/jops-core.min.js" data-app="./src/App.js" data-native-back="off"></script>
231
+ ```
232
+
233
+ **Capacitor:** install the official App plugin — no code is needed:
234
+
235
+ ```bash
236
+ npm i @capacitor/app
237
+ npx cap sync
238
+ ```
239
+
240
+ Capacitor's Cordova compatibility layer fires `deviceready` and forwards JOPS's `backbutton` listener to the plugin, so the behaviour above applies unchanged. Without `@capacitor/app`, Capacitor does not intercept back at all and the app closes on the first press; JOPS logs a console warning in that case.
241
+
242
+ > **Do not** also register `App.addListener("backButton", ...)` while the built-in handler is on: the plugin calls your listener *and* fires `backbutton`, so every press would go back twice.
243
+
244
+ To handle back yourself in Capacitor, opt out with `data-native-back="off"` and use the plugin's global (no bundler needed):
245
+
246
+ ```js
247
+ const { App } = window.Capacitor.Plugins;
248
+
249
+ App.addListener("backButton", () => {
250
+ const router = Router.singleton();
251
+ if (router?.canGoBack()) router.back();
252
+ else App.exitApp();
253
+ });
254
+ ```
255
+
256
+ ### Notches, Dynamic Island and the home indicator
257
+
258
+ With `viewport-fit=cover` (added by `npx jops-core init` in Cordova and Capacitor projects) the page extends under the status bar and the home indicator. Pad fixed or sticky bars with the safe-area insets — they are `0` in a regular browser, so the layout is unchanged there:
259
+
260
+ ```css
261
+ .header {
262
+ padding-top: calc(1rem + env(safe-area-inset-top, 0px));
263
+ }
264
+ .footer {
265
+ padding-bottom: calc(2rem + env(safe-area-inset-bottom, 0px));
266
+ }
267
+ ```
268
+
269
+ The Hello World template already does this.
270
+
271
+ ### Debugging
272
+
273
+ - **Android:** open `chrome://inspect` in desktop Chrome while the emulator or a device runs the debug build.
274
+ - **iOS:** Safari → Develop → *Simulator* → your app.
275
+
276
+ A `cordova_plugins.js` "not found" error in the console is expected when the project has no Cordova plugins installed; it is harmless.
277
+
278
+ ---
279
+
120
280
  ## Class Reference
121
281
 
122
282
  ### `View` — Base Class
@@ -138,7 +298,7 @@ export default class MyView extends View { ... }
138
298
  | `async onLayout()` | DOM is live — add subviews, read initial data |
139
299
  | `async onResume()` | View becomes visible — called on first display and on every return navigation |
140
300
  | `bindEvents()` | Wires `on*` attributes on `[jops-event-bind]` elements to class methods |
141
- | `onBackPressed()` | Called by Router when back navigation is detected on this view |
301
+ | `onBackPressed()` | Called by Router when the user navigates back from this view (browser back, `Router.back()`, Android back button in hybrid apps) — never on forward navigation |
142
302
 
143
303
  #### Instance Methods
144
304
 
@@ -266,6 +426,7 @@ Hash-based SPA router. Singleton. Declared in the layout as `<Router jops animat
266
426
  | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
267
427
  | `navigate(path, ClassDef?)` | Creates a new view instance for `path`, pushes onto the nav stack, animates forward. Updates address bar to `#path?params`. `ClassDef` required for dynamic routes not in the static route map |
268
428
  | `back()` | Navigates back — pops the nav stack with animation if non-empty, otherwise calls `history.back()` |
429
+ | `canGoBack()` | Returns `true` if there is an earlier JOPS history entry to go back to (like checking the back stack in Android) |
269
430
  | `replace(path, ClassDef?)` | Clears the nav stack and navigates to `path`. Uses existing flat route instance if available, otherwise creates a new one from `ClassDef` |
270
431
  | `getParams()` | Returns the current URL query params as a plain object. Works for both static (`href="#path?k=v"`) and dynamic (`navigate("/path?k=v")`) routes |
271
432
 
@@ -393,9 +554,11 @@ t.terminate();
393
554
 
394
555
  | Command | Description |
395
556
  | ---------------------- | -------------------------------------------------------------------------- |
396
- | `npx jops-core init` | Vendor library, configure `index.html`, inject scripts into `package.json` |
557
+ | `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)) |
397
558
  | `npx jops-core update` | Copy the latest `jops-core.min.js` from `node_modules` to `lib/` |
398
- | `npx jops-core build` | Generate production-ready `dist/` folder |
559
+ | `npx jops-core build` | Generate production-ready `dist/` folder (skipped in Cordova/Capacitor projects) |
560
+
561
+ `init` and `update` accept `--cordova`, `--capacitor` or `--web` to override the detected project type.
399
562
 
400
563
  ---
401
564
 
package/bin/init.js CHANGED
@@ -4,18 +4,71 @@ import path from "path";
4
4
  import readline from "readline";
5
5
  import { fileURLToPath } from "url";
6
6
 
7
- const [,, command] = process.argv;
7
+ const [,, command, ...flags] = process.argv;
8
8
 
9
9
  if (command !== "init" && command !== "build" && command !== "update") {
10
10
  console.log("Usage:");
11
11
  console.log(" npx jops-core init — vendor library and configure index.html");
12
12
  console.log(" npx jops-core update — copy the latest jops-core.min.js to lib/");
13
13
  console.log(" npx jops-core build — generate production-ready dist/ folder");
14
+ console.log("");
15
+ console.log("Options (init, update):");
16
+ console.log(" --cordova | --capacitor | --web override the detected project type");
14
17
  process.exit(0);
15
18
  }
16
19
 
17
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
- const consumerRoot = process.cwd();
21
+
22
+ // ─── project detection ───────────────────────────────────────────────────────
23
+ // projectRoot: package.json, .vscode/ webRoot: index.html, lib/, src/
24
+ // They differ in hybrid projects: Cordova serves www/, Capacitor serves its webDir.
25
+
26
+ const forced = ["--cordova", "--capacitor", "--web"]
27
+ .find((f) => flags.includes(f))?.slice(2);
28
+
29
+ function readCapacitorWebDir(dir) {
30
+ for (const name of ["capacitor.config.json", "capacitor.config.ts", "capacitor.config.js"]) {
31
+ const p = path.join(dir, name);
32
+ if (!fs.existsSync(p)) continue;
33
+ const text = fs.readFileSync(p, "utf8");
34
+ if (name.endsWith(".json")) return JSON.parse(text).webDir ?? "www";
35
+ return text.match(/webDir\s*:\s*["'`]([^"'`]+)["'`]/)?.[1] ?? "www";
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function isCordovaRoot(dir) {
41
+ const p = path.join(dir, "config.xml");
42
+ return fs.existsSync(p) && /<widget\b/.test(fs.readFileSync(p, "utf8"));
43
+ }
44
+
45
+ // Returns { type, webRoot } when `dir` is a hybrid project root, otherwise null.
46
+ // Capacitor first: a project migrated from Cordova may still contain config.xml.
47
+ function detectHybrid(dir) {
48
+ const webDir = readCapacitorWebDir(dir);
49
+ if (webDir) return { type: "capacitor", webRoot: path.resolve(dir, webDir) };
50
+ if (isCordovaRoot(dir)) return { type: "cordova", webRoot: path.join(dir, "www") };
51
+ return null;
52
+ }
53
+
54
+ function resolveProject(cwd) {
55
+ if (forced !== "web") {
56
+ const here = detectHybrid(cwd); // run from the project root
57
+ if (here) return { type: forced ?? here.type, projectRoot: cwd, webRoot: here.webRoot };
58
+ // Run from inside www/ (or a nested webDir such as dist/app): find the nearest project root above.
59
+ for (let dir = path.dirname(cwd); dir !== path.dirname(dir); dir = path.dirname(dir)) {
60
+ const up = detectHybrid(dir);
61
+ if (!up) continue;
62
+ if (up.webRoot === cwd) return { type: forced ?? up.type, projectRoot: dir, webRoot: cwd };
63
+ break;
64
+ }
65
+ }
66
+ return { type: forced ?? "web", projectRoot: cwd, webRoot: cwd };
67
+ }
68
+
69
+ const { type, projectRoot, webRoot } = resolveProject(process.cwd());
70
+ const isHybrid = type !== "web";
71
+ const rel = (p) => path.relative(projectRoot, p) || ".";
19
72
 
20
73
  function copyDir(src, dest) {
21
74
  const SKIP = new Set(["node_modules", "dist", ".git", "package.json", "package-lock.json", ".DS_Store"]);
@@ -30,22 +83,124 @@ function copyDir(src, dest) {
30
83
  copyDir(srcPath, destPath);
31
84
  } else if (!fs.existsSync(destPath)) {
32
85
  fs.copyFileSync(srcPath, destPath);
33
- console.log(`[jops-core] Created ${path.relative(consumerRoot, destPath)}`);
86
+ console.log(`[jops-core] Created ${rel(destPath)}`);
34
87
  }
35
88
  }
36
89
  }
37
90
 
91
+ // ─── index.html helpers (hybrid projects) ────────────────────────────────────
92
+
93
+ // Same policy as Cordova's default, plus 'unsafe-inline' for the inline import map
94
+ // and blob: for Thread workers.
95
+ const CORDOVA_CSP =
96
+ "default-src 'self' data: blob: https://ssl.gstatic.com; script-src 'self' 'unsafe-inline' blob:; " +
97
+ "style-src 'self' 'unsafe-inline'; connect-src 'self' data: blob:; media-src * blob:; " +
98
+ "img-src 'self' data: blob: content:;";
99
+
100
+ // Let the page extend under the notch; safe-area CSS takes care of padding.
101
+ function ensureViewportFitCover(html) {
102
+ const tag = html.match(/<meta\b[^>]*name=["']viewport["'][^>]*>/i)?.[0];
103
+ if (!tag) {
104
+ return html.replace(
105
+ /<head[^>]*>/i,
106
+ (head) => `${head}\n <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />`,
107
+ );
108
+ }
109
+ if (/viewport-fit/i.test(tag)) return html;
110
+ return html.replace(
111
+ tag,
112
+ tag.replace(/content=(["'])((?:(?!\1).)*)\1/i, (_, q, c) => `content=${q}${c}, viewport-fit=cover${q}`),
113
+ );
114
+ }
115
+
116
+ // Cordova's default CSP blocks the inline import map ('unsafe-inline') and
117
+ // Thread's blob: workers (worker-src falls back to script-src).
118
+ function allowJopsScripts(html) {
119
+ const re = /(<meta\b[^>]*http-equiv=["']Content-Security-Policy["'][^>]*content=)(["'])((?:(?!\2).)*)\2/i;
120
+ const m = html.match(re);
121
+ if (!m) return html; // no CSP → nothing to relax
122
+ const directives = m[3].split(";").map((d) => d.trim()).filter(Boolean);
123
+ const i = directives.findIndex((d) => /^script-src\b/.test(d));
124
+ const required = ["'unsafe-inline'", "blob:"];
125
+ if (i >= 0) {
126
+ const missing = required.filter((src) => !directives[i].split(/\s+/).includes(src));
127
+ if (!missing.length) return html;
128
+ directives[i] += " " + missing.join(" ");
129
+ } else {
130
+ // No script-src: scripts fall back to default-src, so copy it and add what JOPS needs.
131
+ const def = directives.find((d) => /^default-src\b/.test(d))?.replace(/^default-src\s*/, "") ?? "'self'";
132
+ const sources = def.split(/\s+/).filter(Boolean);
133
+ directives.push(`script-src ${[...sources, ...required.filter((src) => !sources.includes(src))].join(" ")}`);
134
+ }
135
+ return html.replace(m[0], `${m[1]}${m[2]}${directives.join("; ")};${m[2]}`);
136
+ }
137
+
138
+ // cordova.js must be present; module scripts run after classic scripts, so order is safe.
139
+ function ensureCordovaJs(html) {
140
+ if (/src=["'](\.\/)?cordova\.js["']/i.test(html)) return html;
141
+ return html.replace(/(\s*<script[^>]+jops-core\.min\.js)/i, `\n <script src="cordova.js"></script>$1`);
142
+ }
143
+
144
+ // ─── config.xml helpers (Cordova) ────────────────────────────────────────────
145
+
146
+ const IOS_SCHEME_PREFS = [["scheme", "app"], ["hostname", "localhost"]];
147
+
148
+ // Preferences that apply to iOS: global ones plus those inside <platform name="ios">.
149
+ function iosPrefScope(xml) {
150
+ return xml
151
+ .replace(/<!--[\s\S]*?-->/g, "")
152
+ .replace(/<platform\s+name=["'](?!ios["'])[^"']*["'][\s\S]*?<\/platform>/gi, "");
153
+ }
154
+
155
+ function prefValue(scope, name) {
156
+ const tag = scope.match(new RegExp(`<preference\\b[^>]*\\bname=["']${name}["'][^>]*>`, "i"))?.[0];
157
+ return tag ? (tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? "") : undefined;
158
+ }
159
+
160
+ // Cordova iOS serves from file:// unless a custom scheme is set; JOPS needs a real origin
161
+ // for import() and fetch(). Existing values are never changed.
162
+ function ensureIosScheme(xml) {
163
+ const scope = iosPrefScope(xml);
164
+ const missing = IOS_SCHEME_PREFS.filter(([name]) => prefValue(scope, name) === undefined);
165
+ if (!missing.length) return { xml, added: [] };
166
+
167
+ const lines = missing.map(([n, v]) => ` <preference name="${n}" value="${v}" />`).join("\n");
168
+ const iosOpen = /<platform\s+name=["']ios["']\s*>/i;
169
+ xml = iosOpen.test(xml)
170
+ ? xml.replace(iosOpen, (m) => `${m}\n${lines}`)
171
+ : xml.replace(/\s*<\/widget>\s*$/i, `\n <platform name="ios">\n${lines}\n </platform>\n</widget>\n`);
172
+ return { xml, added: missing.map(([n]) => n) };
173
+ }
174
+
175
+ function schemeWarnings(xml) {
176
+ const warnings = [];
177
+ const scheme = prefValue(iosPrefScope(xml), "scheme");
178
+ if (scheme !== undefined && /^(file|https?)?$/i.test(scheme)) {
179
+ warnings.push(`iOS preference scheme="${scheme}" keeps the app on file:// or is not allowed by WKWebView — JOPS needs a custom scheme such as "app".`);
180
+ }
181
+ const insecure = prefValue(xml.replace(/<!--[\s\S]*?-->/g, ""), "AndroidInsecureFileModeEnabled");
182
+ if (insecure?.toLowerCase() === "true") {
183
+ warnings.push("AndroidInsecureFileModeEnabled=true serves Android from file://, where JOPS cannot load modules.");
184
+ }
185
+ return warnings;
186
+ }
187
+
38
188
  // ─── init ────────────────────────────────────────────────────────────────────
39
189
 
40
190
  if (command === "init") {
41
- const indexPath = path.join(consumerRoot, "index.html");
42
- const libDir = path.join(consumerRoot, "lib");
191
+ fs.mkdirSync(webRoot, { recursive: true });
192
+ if (isHybrid) {
193
+ console.log(`[jops-core] ${forced ? "Using" : "Detected"} ${type} project → web folder: ${rel(webRoot)}/`);
194
+ }
195
+
196
+ const indexPath = path.join(webRoot, "index.html");
197
+ const libDir = path.join(webRoot, "lib");
43
198
  const vendorDest = path.join(libDir, "jops-core.min.js");
44
199
  const vendorSrc = path.join(__dirname, "../lib/jops-core.min.js");
45
200
 
46
201
  if (!fs.existsSync(libDir)) fs.mkdirSync(libDir);
47
202
  fs.copyFileSync(vendorSrc, vendorDest);
48
- console.log("[jops-core] Vendored jops-core.min.js → lib/jops-core.min.js");
203
+ console.log(`[jops-core] Vendored jops-core.min.js → ${rel(vendorDest)}`);
49
204
 
50
205
  const IMPORTMAP = ` <script type="importmap">
51
206
  {
@@ -57,16 +212,25 @@ if (command === "init") {
57
212
 
58
213
  const MODULE_SCRIPT = ` <script type="module" src="/lib/jops-core.min.js"></script>`;
59
214
 
215
+ const viewport = isHybrid
216
+ ? "width=device-width, initial-scale=1.0, viewport-fit=cover"
217
+ : "width=device-width, initial-scale=1.0";
218
+ const cordovaHead = type === "cordova"
219
+ ? ` <meta http-equiv="Content-Security-Policy" content="${CORDOVA_CSP}" />\n` +
220
+ ` <meta name="format-detection" content="telephone=no" />\n`
221
+ : "";
222
+ const cordovaBody = type === "cordova" ? ` <script src="cordova.js"></script>\n` : "";
223
+
60
224
  const TEMPLATE = `<!doctype html>
61
225
  <html lang="en">
62
226
  <head>
63
227
  <meta charset="UTF-8" />
64
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
228
+ ${cordovaHead} <meta name="viewport" content="${viewport}" />
65
229
  <title>JOPS App</title>
66
230
  ${IMPORTMAP}
67
231
  </head>
68
232
  <body>
69
- ${MODULE_SCRIPT}
233
+ ${cordovaBody}${MODULE_SCRIPT}
70
234
  </body>
71
235
  </html>
72
236
  `;
@@ -76,31 +240,60 @@ ${MODULE_SCRIPT}
76
240
  console.log("[jops-core] Created index.html with importmap and module script.");
77
241
  } else {
78
242
  let html = fs.readFileSync(indexPath, "utf8");
79
- let changed = false;
243
+ const original = html;
80
244
 
81
245
  const hasImportmap = /type=["']importmap["']/.test(html) && html.includes('"jops-core"');
82
246
  if (!hasImportmap) {
83
247
  html = html.replace("</head>", `${IMPORTMAP}\n </head>`);
84
- changed = true;
85
248
  console.log("[jops-core] Added importmap to index.html.");
86
249
  }
87
250
 
88
251
  const hasModuleScript = /src=["'][^"']*jops-core\.min\.js["']/.test(html);
89
252
  if (!hasModuleScript) {
90
253
  html = html.replace("</body>", `${MODULE_SCRIPT}\n </body>`);
91
- changed = true;
92
254
  console.log("[jops-core] Added module script to index.html.");
93
255
  }
94
256
 
95
- if (changed) {
257
+ if (isHybrid) {
258
+ const before = html;
259
+ html = ensureViewportFitCover(html);
260
+ if (html !== before) console.log("[jops-core] Added viewport-fit=cover to index.html.");
261
+ }
262
+
263
+ if (type === "cordova") {
264
+ let before = html;
265
+ html = allowJopsScripts(html);
266
+ if (html !== before) console.log("[jops-core] Content-Security-Policy: allowed the inline importmap ('unsafe-inline') and Thread workers (blob:).");
267
+ before = html;
268
+ html = ensureCordovaJs(html);
269
+ if (html !== before) console.log("[jops-core] Added cordova.js to index.html.");
270
+ }
271
+
272
+ if (html !== original) {
96
273
  fs.writeFileSync(indexPath, html, "utf8");
97
274
  } else {
98
275
  console.log("[jops-core] index.html already configured.");
99
276
  }
100
277
  }
101
278
 
102
- // VS Code extension recommendation
103
- const vscodeSettingsDir = path.join(consumerRoot, ".vscode");
279
+ if (type === "cordova") {
280
+ const configPath = path.join(projectRoot, "config.xml");
281
+ if (fs.existsSync(configPath)) {
282
+ const { xml, added } = ensureIosScheme(fs.readFileSync(configPath, "utf8"));
283
+ if (added.length) {
284
+ fs.writeFileSync(configPath, xml, "utf8");
285
+ const list = IOS_SCHEME_PREFS.filter(([n]) => added.includes(n)).map(([n, v]) => `${n}="${v}"`).join(", ");
286
+ console.log(`[jops-core] config.xml: added iOS preference ${list} — applied on the next cordova prepare/build.`);
287
+ if (added.includes("scheme")) {
288
+ console.log("[jops-core] Note: if this app is already published, data stored under file:// (localStorage, IndexedDB) is not visible from the new origin.");
289
+ }
290
+ }
291
+ schemeWarnings(xml).forEach((w) => console.warn(`[jops-core] Warning: ${w}`));
292
+ }
293
+ }
294
+
295
+ // VS Code extension recommendation (project root, so it is never bundled into a hybrid app)
296
+ const vscodeSettingsDir = path.join(projectRoot, ".vscode");
104
297
  if (!fs.existsSync(vscodeSettingsDir)) fs.mkdirSync(vscodeSettingsDir);
105
298
 
106
299
  const extensionsJsonPath = path.join(vscodeSettingsDir, "extensions.json");
@@ -139,11 +332,12 @@ ${MODULE_SCRIPT}
139
332
  console.log("[jops-core] VS Code: install the recommended 'lit-html' extension (bierner.lit-html) for HTML highlighting inside layout`` template literals.");
140
333
 
141
334
  // Inject scripts into consumer's package.json
142
- const pkgPath = path.join(consumerRoot, "package.json");
335
+ const pkgPath = path.join(projectRoot, "package.json");
143
336
  if (fs.existsSync(pkgPath)) {
144
337
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
145
338
  let changed = false;
146
- if (!pkg.scripts?.build) {
339
+ // Hybrid projects build with cordova/cap; `jops-core build` would copy the native platform folders.
340
+ if (!isHybrid && !pkg.scripts?.build) {
147
341
  pkg.scripts = { ...pkg.scripts, build: "npx jops-core build" };
148
342
  changed = true;
149
343
  console.log('[jops-core] Added "build" script to package.json.');
@@ -169,9 +363,20 @@ ${MODULE_SCRIPT}
169
363
  );
170
364
  rl.close();
171
365
 
366
+ // Cordova's sample page (css/index.css, js/index.js, #deviceready) conflicts with a JOPS
367
+ // template: back it up and start from the JOPS Cordova index.html instead.
368
+ if (type === "cordova" && ["1", "2"].includes(choice.trim()) &&
369
+ fs.readFileSync(indexPath, "utf8").includes('id="deviceready"')) {
370
+ let backup = path.join(webRoot, "index.cordova.html");
371
+ for (let n = 1; fs.existsSync(backup); n++) backup = path.join(webRoot, `index.cordova-${n}.html`);
372
+ fs.renameSync(indexPath, backup);
373
+ fs.writeFileSync(indexPath, TEMPLATE, "utf8");
374
+ console.log(`[jops-core] Replaced Cordova's sample index.html (backup: ${rel(backup)}). css/index.css and js/index.js are no longer used.`);
375
+ }
376
+
172
377
  const templateSrc = path.join(__dirname, "../templates");
173
378
  if (choice.trim() === "1") {
174
- copyDir(path.join(templateSrc, "empty"), consumerRoot);
379
+ copyDir(path.join(templateSrc, "empty"), webRoot);
175
380
  // Add data-app to the module script in index.html
176
381
  let html = fs.readFileSync(indexPath, "utf8");
177
382
  html = html.replace(
@@ -181,7 +386,7 @@ ${MODULE_SCRIPT}
181
386
  fs.writeFileSync(indexPath, html, "utf8");
182
387
  console.log("[jops-core] Created empty JOPS project skeleton.");
183
388
  } else if (choice.trim() === "2") {
184
- copyDir(path.join(templateSrc, "hello-world"), consumerRoot);
389
+ copyDir(path.join(templateSrc, "hello-world"), webRoot);
185
390
  let html = fs.readFileSync(indexPath, "utf8");
186
391
  html = html.replace(
187
392
  /(<script[^>]+src=["'][^"']*jops-core\.min\.js["'])([^>]*>)/,
@@ -197,24 +402,30 @@ ${MODULE_SCRIPT}
197
402
  // ─── update ──────────────────────────────────────────────────────────────────
198
403
 
199
404
  if (command === "update") {
200
- const libDir = path.join(consumerRoot, "lib");
405
+ const libDir = path.join(webRoot, "lib");
201
406
  const vendorDest = path.join(libDir, "jops-core.min.js");
202
407
  const vendorSrc = path.join(__dirname, "../lib/jops-core.min.js");
203
408
 
204
409
  if (!fs.existsSync(libDir)) {
205
- console.error("[jops-core] lib/ folder not found. Run 'npx jops-core init' first.");
410
+ console.error(`[jops-core] ${rel(libDir)}/ folder not found. Run 'npx jops-core init' first.`);
206
411
  process.exit(1);
207
412
  }
208
413
 
209
414
  fs.copyFileSync(vendorSrc, vendorDest);
210
415
 
211
416
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../package.json"), "utf8"));
212
- console.log(`[jops-core] Updated lib/jops-core.min.js to v${pkg.version}`);
417
+ console.log(`[jops-core] Updated ${rel(vendorDest)} to v${pkg.version}`);
213
418
  }
214
419
 
215
420
  // ─── build ───────────────────────────────────────────────────────────────────
216
421
 
217
422
  if (command === "build") {
423
+ if (isHybrid) {
424
+ const native = type === "cordova" ? "cordova build" : "npx cap sync";
425
+ console.log(`[jops-core] Not needed in a ${type} project — ${rel(webRoot)}/ is shipped as-is. Use \`${native}\`.`);
426
+ process.exit(0);
427
+ }
428
+
218
429
  const BUILD_EXCLUDE = new Set([
219
430
  "node_modules", "dist", ".git",
220
431
  "package.json", "package-lock.json", ".gitignore", ".npmrc",
@@ -235,8 +446,8 @@ if (command === "build") {
235
446
  }
236
447
  }
237
448
 
238
- const distDir = path.join(consumerRoot, "dist");
449
+ const distDir = path.join(webRoot, "dist");
239
450
  if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true, force: true });
240
- buildCopyDir(consumerRoot, distDir);
451
+ buildCopyDir(webRoot, distDir);
241
452
  console.log("[jops-core] Built → dist/");
242
453
  }
@@ -72,9 +72,12 @@ export declare class View {
72
72
  */
73
73
  constructor(layout?: string);
74
74
  /**
75
- * Override to load a layout `.js` module or set `this.layout` inline.
75
+ * Override to load a layout file or set `this.layout` inline.
76
76
  * Called automatically by the framework before `inflate()`.
77
- * @param {string} [layout] - Path to a `.js` layout module or an inline HTML string.
77
+ * @param {string} [layout] - Path to a `.js` layout module, a `.html` file, or an inline HTML string.
78
+ * `.js` — dynamically imported as an ES module; must export a `layout\`...\`` string as default.
79
+ * `.html` — fetched and cached for the page lifetime; plain HTML fragment, no import/export needed.
80
+ * Any other value — treated as an inline HTML string assigned directly to `this.layout`.
78
81
  * @returns {Promise<void>}
79
82
  */
80
83
  loadLayout(layout?: string): Promise<void>;
@@ -241,7 +244,7 @@ export declare class Router extends View {
241
244
  routes: {};
242
245
  _navStack: any[];
243
246
  _baseView: any;
244
- _pendingBack: boolean;
247
+ _idx: any;
245
248
  _currentView: any;
246
249
  /**
247
250
  * Returns the single Router instance after it has been rendered.
@@ -251,6 +254,14 @@ export declare class Router extends View {
251
254
  constructor(routerDomNode: any);
252
255
  onLayout(): Promise<void>;
253
256
  initRouter(): void;
257
+ /**
258
+ * Single entry point for popstate and hashchange. Decides the direction
259
+ * from the history entry's position and dispatches to _onBack/_onForward.
260
+ */
261
+ _onHistoryChange(): void;
262
+ _onBack(): void;
263
+ _onForward(): void;
264
+ _switchToHash(): void;
254
265
  _animateTransition(outgoing: any, incoming: any, direction: any): Promise<void>;
255
266
  _slideTransition(outgoing: any, incoming: any, direction: any): Promise<void>;
256
267
  _fadeTransition(outgoing: any, incoming: any): Promise<void>;
@@ -260,6 +271,12 @@ export declare class Router extends View {
260
271
  * otherwise calls `history.back()`.
261
272
  */
262
273
  back(): void;
274
+ /**
275
+ * Returns true if there is an earlier JOPS history entry to go back to.
276
+ * Equivalent to checking the back stack before calling `onBackPressed()` in Android.
277
+ * @returns {boolean}
278
+ */
279
+ canGoBack(): boolean;
263
280
  /**
264
281
  * Returns the current URL query params as a plain object.
265
282
  * Works for both flat (`href="#path?k=v"`) and stack (`navigate("/path?k=v")`) routes.
@@ -1 +1 @@
1
- var k=Object.defineProperty;var c=(i,t)=>k(i,"name",{value:t,configurable:!0});var _=new URL(import.meta.url).origin!==location.origin,p=new Map;function f(i){if(!i)return i;if(_){if(i.startsWith("/"))return"."+i}else if(i.startsWith("./"))return i.slice(1);return i}c(f,"normalizePath");(()=>{console.log("JOPS initialized");let i=document.querySelector("script[data-app]")?.dataset.app??"./src/App.js";import(new URL(f(i),document.baseURI).href).then(t=>new t.default).catch(t=>console.error("JOPS: App.js not found or failed to load",t))})();var m=class{static{c(this,"RawHTML")}constructor(t){this.html=t}},E=c(i=>new m(i),"raw");function v(i,...t){return i.reduce((e,s,n)=>{if(n>=t.length)return e+s;let o=t[n];return o==null?e+s:o instanceof m?e+s+o.html:e+s+String(o).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")},"")}c(v,"layout");var w=class{static{c(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}},y=class i{static{c(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(p.has(e))this.layout=p.get(e);else{let s=await fetch(e);if(!s.ok)throw new Error(`HTTP ${s.status}`);let n=await s.text();p.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,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 w(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 n=s.value.trim();typeof this[n]=="function"&&(e[s.name]=o=>this[n](o))}}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 l=new b(s);await l.render(),await l.onLayout();return}let n=s.getAttribute("layout"),o=s.getAttribute("src");if(!o&&!n)return;let r;if(o){let h=(await import(new URL(f(o),document.baseURI).href)).default;r=new h}else r=new i;this.subViews.push(r),await r.loadLayout(n);let a=document.createElement("template");a.innerHTML=r.layout??"";let d=a.content.cloneNode(!0);if(s.children.length>0){let l=d.firstElementChild??d;Array.from(s.children).forEach(h=>l.appendChild(h))}let u=d.firstElementChild;r.routePath=s.getAttribute("path"),s.replaceWith(d),r.domNode=u,await r.render(),await r?.onLayout?.(),await r?.onResume?.(),r?.bindEvents?.()}))}},R=class i{static{c(this,"EventBus")}static#t={};static subscribe(t,e){i.#t[t]||(i.#t[t]=[]),i.#t[t].push(e)}static send(t,e){i.#t[t]?.forEach(s=>s(e))}static unsubscribe(t,e){i.#t[t]&&(i.#t[t]=i.#t[t].filter(s=>s!==e))}},S=class i{static{c(this,"Store")}static#t=null;static singleton(){return i.#t||(i.#t=new i),i.#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(),o=s.reduce((r,a)=>(r[a]||(r[a]={}),r[a]),this._state);o[n]=e}clear(){this._state={}}},b=class i extends y{static{c(this,"Router")}static#t=null;static singleton(){return i.#t}constructor(t){super(),i.#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 o of this.subViews)if(o.routePath){let r=o.routePath.slice(1).split("?")[0];this.routes[r]=o}this._navStack=[],this._baseView=null;for(let o of Object.values(this.routes))o.domNode.hidden=!0;let t=Object.keys(this.routes)[0],e=window.location.hash.slice(1),s=e.split("?")[0],n=s&&this.routes[s]?e:t;history.replaceState(null,"","#"+n),window.addEventListener("hashchange",()=>{let o=window.location.hash.slice(1),r=o.split("?")[0];if(!(!r||!this.routes[r])){if(this._pendingBack){this._pendingBack=!1;return}this._switchTo(o)}}),window.addEventListener("popstate",()=>{if(this._navStack.length>0){let a=this._navStack.pop(),d=this._navStack.at(-1)??this._baseView;a?.onBackPressed?.(),this._animateTransition(a,d,"back").then(()=>a.destroy());return}this._currentView?.onBackPressed?.();let o=window.location.hash.slice(1),r=o.split("?")[0];r&&this.routes[r]&&(this._pendingBack=!0,this._switchTo(o))}),this._switchTo(n)}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 n=this.domNode,o=s==="forward",r=n.getBoundingClientRect(),a=t.domNode.getBoundingClientRect(),d=a.left-r.left,u=a.top-r.top,l=a.width,h=n.offsetWidth-d,N=`position:absolute;left:${d}px;top:${u}px;width:${l}px`;n.style.cssText=`position:relative;overflow:hidden;height:${n.offsetHeight}px`,t.domNode.style.cssText=`${N};z-index:${o?1:2}`,t.domNode.hidden=!1,e.domNode.style.cssText=`${N};z-index:${o?2:1}`,e.domNode.hidden=!1;let L=o?e.domNode:t.domNode,g=o?`${h}px`:"0px",V=o?"0px":`${h}px`,T=L.animate([{transform:`translateX(${g})`},{transform:`translateX(${V})`}],{duration:300,easing:"ease",fill:"forwards"});await T.finished,T.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",n.style.cssText="",this._currentView=e,e?.onResume?.()}async _fadeTransition(t,e){let s=this.domNode,n=s.getBoundingClientRect(),o=t.domNode.getBoundingClientRect(),r=o.left-n.left,a=o.top-n.top,d=o.width,u=`position:absolute;left:${r}px;top:${a}px;width:${d}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 l=t.domNode.animate([{opacity:1},{opacity:0}],{duration:450,easing:"ease",fill:"forwards"}),h=e.domNode.animate([{opacity:0},{opacity:1}],{duration:450,easing:"ease",fill:"forwards"});await Promise.all([l.finished,h.finished]),l.cancel(),h.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,n]of Object.entries(this.routes)){let o=s===e;n.domNode.hidden=!o,o&&(this._currentView=n,n?.onResume?.())}}back(){history.back()}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("?"),o=s,r=e??this.routes[o]?.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},"","#"+o+(n?"?"+n:"")),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,n]=t.replace(/^\//,"").split("?"),o=s,r="#"+o+(n?"?"+n:"");if(this.routes[o])history.replaceState(null,"",r),this._switchTo(o);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(null,"",r)}else console.error(`JOPS Router: no instance or class found for path "${t}"`)}},x=class{static{c(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=o=>this.onMessage(o),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{R as EventBus,b as Router,S as Store,x as Thread,y as View,v as layout,E as raw};
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};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jops-core",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "A no-bundler, OOP-first SPA framework with Android-style view composition",
5
5
  "type": "module",
6
6
  "exports": "./lib/jops-core.min.js",
@@ -43,7 +43,16 @@
43
43
  "view",
44
44
  "state-management",
45
45
  "webview",
46
- "llm-friendly"
46
+ "llm-friendly",
47
+ "cordova",
48
+ "capacitor",
49
+ "ionic",
50
+ "mobile",
51
+ "android",
52
+ "ios",
53
+ "hybrid-app",
54
+ "phonegap",
55
+ "hybrid-mobile"
47
56
  ],
48
57
  "author": "arik@ui-db.com",
49
58
  "license": "MIT",
@@ -54,7 +54,11 @@ body {
54
54
  display: flex;
55
55
  align-items: center;
56
56
  justify-content: space-between;
57
- padding: var(--spacing-md) var(--spacing-lg);
57
+ /* safe-area insets are 0 unless the page uses viewport-fit=cover (Cordova/Capacitor) */
58
+ padding: calc(var(--spacing-md) + env(safe-area-inset-top, 0px))
59
+ calc(var(--spacing-lg) + env(safe-area-inset-right, 0px))
60
+ var(--spacing-md)
61
+ calc(var(--spacing-lg) + env(safe-area-inset-left, 0px));
58
62
  background: var(--color-surface);
59
63
  border-bottom: 1px solid var(--color-border);
60
64
  box-shadow: var(--shadow-sm);
@@ -452,7 +456,8 @@ body {
452
456
  /* ── Footer ──────────────────────────────────────────────────── */
453
457
  .footer {
454
458
  text-align: center;
455
- padding: var(--spacing-lg);
459
+ padding: var(--spacing-lg) var(--spacing-lg)
460
+ calc(var(--spacing-lg) + env(safe-area-inset-bottom, 0px));
456
461
  background: var(--color-surface);
457
462
  border-top: 1px solid var(--color-border);
458
463
  display: flex;