jops-core 1.0.12 → 1.0.13
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 +166 -3
- package/bin/init.js +234 -23
- package/lib/jops-core.d.ts +20 -3
- package/lib/jops-core.min.js +1 -1
- package/package.json +1 -1
- package/templates/hello-world/src/css/styles.css +7 -2
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
42
|
-
|
|
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(
|
|
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="
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
103
|
-
|
|
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(
|
|
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
|
-
|
|
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"),
|
|
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"),
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
449
|
+
const distDir = path.join(webRoot, "dist");
|
|
239
450
|
if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true, force: true });
|
|
240
|
-
buildCopyDir(
|
|
451
|
+
buildCopyDir(webRoot, distDir);
|
|
241
452
|
console.log("[jops-core] Built → dist/");
|
|
242
453
|
}
|
package/lib/jops-core.d.ts
CHANGED
|
@@ -72,9 +72,12 @@ export declare class View {
|
|
|
72
72
|
*/
|
|
73
73
|
constructor(layout?: string);
|
|
74
74
|
/**
|
|
75
|
-
* Override to load a layout
|
|
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
|
-
|
|
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.
|
package/lib/jops-core.min.js
CHANGED
|
@@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},"")}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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},"")}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
|
@@ -54,7 +54,11 @@ body {
|
|
|
54
54
|
display: flex;
|
|
55
55
|
align-items: center;
|
|
56
56
|
justify-content: space-between;
|
|
57
|
-
|
|
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;
|