jops-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +297 -0
- package/bin/init.js +223 -0
- package/lib/jops-core.min.js +5 -0
- package/package.json +34 -0
- package/templates/empty/src/App.js +11 -0
- package/templates/empty/src/layout/mainlayout.js +2 -0
- package/templates/hello-world/assets/jops-hierarchy.svg +57 -0
- package/templates/hello-world/assets/jops-lifecycle.svg +51 -0
- package/templates/hello-world/assets/jops-navigation.svg +72 -0
- package/templates/hello-world/src/App.js +21 -0
- package/templates/hello-world/src/Contact.js +20 -0
- package/templates/hello-world/src/DataSection.js +99 -0
- package/templates/hello-world/src/Features.js +68 -0
- package/templates/hello-world/src/Form.js +21 -0
- package/templates/hello-world/src/Media.js +93 -0
- package/templates/hello-world/src/css/styles.css +488 -0
- package/templates/hello-world/src/layout/layout_data.js +25 -0
- package/templates/hello-world/src/layout/layout_footer.js +8 -0
- package/templates/hello-world/src/layout/layout_form.js +44 -0
- package/templates/hello-world/src/layout/layout_header.js +17 -0
- package/templates/hello-world/src/layout/layout_hero.js +9 -0
- package/templates/hello-world/src/layout/mainlayout.js +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# JOPS — JavaScript OOP SPA Framework
|
|
2
|
+
|
|
3
|
+
A no-bundler, vanilla-JavaScript SPA framework for developers who think in OOP and native-mobile patterns.
|
|
4
|
+
|
|
5
|
+
## What is JOPS?
|
|
6
|
+
|
|
7
|
+
JOPS is a lightweight SPA framework built on native ES6 modules. It discards the complexity of modern build toolchains while keeping a structured, class-based architecture inspired by Android's view and lifecycle model.
|
|
8
|
+
|
|
9
|
+
No Webpack. No Babel. No compiler. Just `<script type="module">` and a browser.
|
|
10
|
+
|
|
11
|
+
## Where JOPS Fits
|
|
12
|
+
|
|
13
|
+
| | No Bundler | OOP Classes | Declarative View Nesting | No Element Registration |
|
|
14
|
+
| --------- | ------------- | ----------- | ------------------------ | ------------------------------ |
|
|
15
|
+
| **JOPS** | ✅ | ✅ | ✅ | ✅ |
|
|
16
|
+
| Lit | ✅ | ✅ | ⚠️ Web Components only | ❌ |
|
|
17
|
+
| Stimulus | ✅ | ✅ | ❌ | ✅ |
|
|
18
|
+
| Alpine.js | ✅ | ❌ | ❌ | ✅ |
|
|
19
|
+
| Angular | ❌ | ✅ | ✅ | ✅ |
|
|
20
|
+
| Svelte | ❌ (compiler) | ✅ | ✅ | ✅ |
|
|
21
|
+
|
|
22
|
+
**Target audience:** Teams building internal tools, dashboards, embedded WebViews, or native mobile hybrid apps who want a structured, class-based frontend architecture without the npm/webpack/babel ecosystem overhead.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
### CDN
|
|
29
|
+
|
|
30
|
+
The fastest way to get started. Add these two tags to any HTML page:
|
|
31
|
+
|
|
32
|
+
```html
|
|
33
|
+
<script type="importmap">
|
|
34
|
+
{
|
|
35
|
+
"imports": {
|
|
36
|
+
"jops-core": "https://cdn.ui-db.com/jops-core/1.0.0/jops-core.min.js"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
<script src="https://cdn.ui-db.com/jops-core/1.0.0/jops-core.min.js" data-app="/src/App.js"></script>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The import map lets your app modules use `import { View } from "jops-core"`. The script tag bootstraps the app entry point specified in `data-app`.
|
|
44
|
+
|
|
45
|
+
`App.js` is your entry point — a file you provide that instantiates your root `View` and mounts it to the page. Point `data-app` to wherever you place it.
|
|
46
|
+
|
|
47
|
+
Replace `1.0.0` with the version you want to pin. No build step, no npm, no bundler.
|
|
48
|
+
|
|
49
|
+
### npm
|
|
50
|
+
|
|
51
|
+
For greenfield projects that want local tooling and scaffolding:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm init -y
|
|
55
|
+
npm install jops-core
|
|
56
|
+
npx jops-core init
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`npx jops-core init` vendors the library to `/lib/jops-core.min.js`, configures `index.html` with the import map, and injects build and postinstall scripts into `package.json`.
|
|
60
|
+
|
|
61
|
+
### Build for production
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm run build
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Generates a clean `dist/` folder ready to serve.
|
|
68
|
+
|
|
69
|
+
### VS Code — HTML highlighting in layout templates
|
|
70
|
+
|
|
71
|
+
Install the [lit-html](https://marketplace.visualstudio.com/items?itemName=bierner.lit-html) extension, then add this to your `.vscode/settings.json`:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"lit-html.tags": ["layout"],
|
|
76
|
+
"editor.tokenColorCustomizations": {
|
|
77
|
+
"textMateRules": [
|
|
78
|
+
{
|
|
79
|
+
"scope": "invalid.illegal.unrecognized-tag.html",
|
|
80
|
+
"settings": { "foreground": "#569CD6" }
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This gives you HTML syntax highlighting, Emmet, and autocomplete inside every `` layout`...` `` tagged template. Custom view tags (e.g. `<MyView>`) are coloured blue like standard HTML elements instead of red. `npx jops-core init` configures this automatically.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Class Reference
|
|
92
|
+
|
|
93
|
+
### `View` — Base Class
|
|
94
|
+
|
|
95
|
+
The foundational building block. Every screen, component, and sub-component extends `View`.
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
import { View } from "jops-core";
|
|
99
|
+
|
|
100
|
+
export default class MyView extends View { ... }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### Lifecycle (called by the framework in order)
|
|
104
|
+
|
|
105
|
+
| Method | When called |
|
|
106
|
+
| ------------------------- | ------------------------------------------------------------------------------ |
|
|
107
|
+
| `async loadLayout(path?)` | Override to load a layout `.js` module |
|
|
108
|
+
| `inflate()` | Converts `this.layout` string into `this.domNode` |
|
|
109
|
+
| `async onLayout()` | DOM is live — add subviews, read initial data |
|
|
110
|
+
| `async onResume()` | View becomes visible — called on first display and on every return navigation |
|
|
111
|
+
| `bindEvents()` | Wires `on*` attributes on `[type="jops-event-bind"]` elements to class methods |
|
|
112
|
+
| `onBackPressed()` | Called by Router when back navigation is detected on this view |
|
|
113
|
+
|
|
114
|
+
#### Instance Methods
|
|
115
|
+
|
|
116
|
+
| Method | Description |
|
|
117
|
+
| ------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
118
|
+
| `get(id)` | Returns an `Element` wrapper for the child with the given DOM `id` |
|
|
119
|
+
| `setStyle(style)` | Injects a `.css` file link or inline style tag into `<head>`, deduped |
|
|
120
|
+
| `addSubView(containerId, view)` | Appends `view` into the child element with `id="containerId"`, runs its full lifecycle |
|
|
121
|
+
| `insertSubView(view, index)` | Inserts `view` at position `index` within `this.domNode`, runs its full lifecycle |
|
|
122
|
+
| `removeSubView(view)` | Removes `view` from DOM and `this.subViews`, calls `view.destroy()` |
|
|
123
|
+
| `find(condition)` | Depth-first search across full subview subtree; returns flat array of all views matching the predicate |
|
|
124
|
+
| `show()` | Restores `this.domNode` display (removes inline `display` override) |
|
|
125
|
+
| `hide()` | Sets `this.domNode` to `display: none` |
|
|
126
|
+
| `destroy()` | Recursively destroys subtree — calls `destroy()` on all subviews, removes `domNode` from DOM, nulls references |
|
|
127
|
+
|
|
128
|
+
#### Declarative Event Binding
|
|
129
|
+
|
|
130
|
+
Mark any element with `type="jops-event-bind"` and add `on*` attributes whose values are method names on the View class:
|
|
131
|
+
|
|
132
|
+
```html
|
|
133
|
+
<button type="jops-event-bind" onclick="onSave">Save</button>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
onSave(event) { ... }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### `Element` — DOM Accessor (returned by `View.get()`)
|
|
143
|
+
|
|
144
|
+
Returned by `view.get(id)`. Wraps a single DOM element for reading and writing.
|
|
145
|
+
|
|
146
|
+
| Method | Description |
|
|
147
|
+
| ------------ | -------------------------------------------------------------------- |
|
|
148
|
+
| `set(value)` | Sets `innerHTML` of the element — accepts plain text or HTML strings |
|
|
149
|
+
| `value()` | Returns the current `innerHTML` of the element |
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
this.get("username").set("Alice");
|
|
153
|
+
const current = this.get("username").value();
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
### `layout` — Tagged Template Function
|
|
159
|
+
|
|
160
|
+
XSS-safe tagged template literal for defining HTML layouts. Dynamic values are HTML-escaped automatically. Use `raw()` to opt out for trusted HTML.
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
import { layout, raw } from "jops-core";
|
|
164
|
+
|
|
165
|
+
const html = layout`<p>${userInput}</p>`; // escaped
|
|
166
|
+
const html = layout`<p>${raw("<b>trusted</b>")}</p>`; // raw HTML
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### `raw(html)` — Function
|
|
172
|
+
|
|
173
|
+
Wraps a trusted HTML string to bypass escaping inside a `layout` template.
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
import { raw } from "jops-core";
|
|
177
|
+
|
|
178
|
+
raw("<b>Hello</b>"); // passed through as-is inside layout``
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
### `Router` — extends `View`
|
|
184
|
+
|
|
185
|
+
Hash-based SPA router. Singleton. Declared in the layout as `<Router type="jops" animation="slide">`.
|
|
186
|
+
|
|
187
|
+
#### Two Navigation Paradigms
|
|
188
|
+
|
|
189
|
+
| Pattern | Mechanism | Instance | Android analogy |
|
|
190
|
+
| ------------------------ | ------------------ | -------------------- | ------------------------------- |
|
|
191
|
+
| `href="#path"` | flat, hashchange | pre-existing, reused | Tab navigation |
|
|
192
|
+
| `navigate(path, Class?)` | hierarchical stack | always new instance | `startActivity()` |
|
|
193
|
+
| `replace(path, Class?)` | resets stack | existing or new | `startActivity(FLAG_CLEAR_TOP)` |
|
|
194
|
+
|
|
195
|
+
#### Static Methods
|
|
196
|
+
|
|
197
|
+
| Method | Description |
|
|
198
|
+
| -------------------- | ---------------------------------- |
|
|
199
|
+
| `Router.singleton()` | Returns the single Router instance |
|
|
200
|
+
|
|
201
|
+
#### Instance Methods
|
|
202
|
+
|
|
203
|
+
| Method | Description |
|
|
204
|
+
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
205
|
+
| `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 |
|
|
206
|
+
| `back()` | Navigates back — pops the nav stack with animation if non-empty, otherwise calls `history.back()` |
|
|
207
|
+
| `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` |
|
|
208
|
+
| `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 |
|
|
209
|
+
|
|
210
|
+
#### Animation
|
|
211
|
+
|
|
212
|
+
Set via the `animation` attribute on the `<Router>` element:
|
|
213
|
+
|
|
214
|
+
| Value | Behaviour |
|
|
215
|
+
| ------------------ | -------------------------------------------------------------------------- |
|
|
216
|
+
| `"none"` (default) | Instant switch, no animation |
|
|
217
|
+
| `"slide"` | Forward: incoming slides in from right. Back: outgoing slides out to right |
|
|
218
|
+
| `"fade"` | Crossfade between outgoing and incoming |
|
|
219
|
+
|
|
220
|
+
Animation only occurs on `navigate()` (forward) and `back()` when the nav stack is non-empty. Flat tab switching is always instant.
|
|
221
|
+
|
|
222
|
+
#### Layout Declaration
|
|
223
|
+
|
|
224
|
+
```html
|
|
225
|
+
<Router type="jops" animation="slide">
|
|
226
|
+
<view type="jops" src="/src/Home.js" path="/home"></view>
|
|
227
|
+
<view type="jops" src="/src/Form.js" path="/form"></view>
|
|
228
|
+
</Router>
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
#### Query Params
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
// static route with params
|
|
235
|
+
<a href="#form?tab=profile">Profile</a>;
|
|
236
|
+
|
|
237
|
+
// dynamic navigation with params
|
|
238
|
+
Router.singleton().navigate("/detail?id=42&mode=edit", DetailView);
|
|
239
|
+
|
|
240
|
+
// read params in any view's onResume()
|
|
241
|
+
const { id, mode } = Router.singleton().getParams();
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
### `Store` — Singleton State Container
|
|
247
|
+
|
|
248
|
+
App-wide runtime state. No reactivity — a plain shared key-value store with dot-notation path access. Views read from Store in `onResume()` and write in event handlers.
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
import { Store } from "jops-core";
|
|
252
|
+
|
|
253
|
+
const store = Store.singleton();
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
| Method | Description |
|
|
257
|
+
| ------------------- | ---------------------------------------------------------------------- |
|
|
258
|
+
| `Store.singleton()` | Returns the single Store instance, creating it on first call |
|
|
259
|
+
| `get(path)` | Reads a value by dot-notation path, e.g. `"user.name"` |
|
|
260
|
+
| `set(path, value)` | Writes a value by dot-notation path; auto-creates intermediate objects |
|
|
261
|
+
| `clear()` | Resets the entire state to `{}` |
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
Store.singleton().set("user.name", "Alice");
|
|
265
|
+
Store.singleton().get("user.name"); // "Alice"
|
|
266
|
+
Store.singleton().set("cart.items", [1, 2, 3]);
|
|
267
|
+
Store.singleton().clear();
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
### `EventBus` — Static Pub/Sub
|
|
273
|
+
|
|
274
|
+
Stateless cross-view event broadcasting. All methods are static — no instance needed.
|
|
275
|
+
|
|
276
|
+
| Method | Description |
|
|
277
|
+
| --------------------------------------- | ---------------------------------------------------------------------- |
|
|
278
|
+
| `EventBus.subscribe(event, callback)` | Registers a listener for the named event |
|
|
279
|
+
| `EventBus.send(event, data?)` | Fires the named event to all subscribers with an optional data payload |
|
|
280
|
+
| `EventBus.unsubscribe(event, callback)` | Removes a specific listener by reference |
|
|
281
|
+
|
|
282
|
+
```js
|
|
283
|
+
import { EventBus } from "jops-core";
|
|
284
|
+
|
|
285
|
+
EventBus.subscribe("user:login", (data) => console.log(data.name));
|
|
286
|
+
EventBus.send("user:login", { name: "Alice" });
|
|
287
|
+
EventBus.unsubscribe("user:login", handler);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## CLI
|
|
293
|
+
|
|
294
|
+
| Command | Description |
|
|
295
|
+
| --------------------- | -------------------------------------------------------------------------- |
|
|
296
|
+
| `npx jops-core init` | Vendor library, configure `index.html`, inject scripts into `package.json` |
|
|
297
|
+
| `npx jops-core build` | Generate production-ready `dist/` folder |
|
package/bin/init.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import readline from "readline";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
|
|
7
|
+
const [,, command] = process.argv;
|
|
8
|
+
|
|
9
|
+
if (command !== "init" && command !== "build") {
|
|
10
|
+
console.log("Usage:");
|
|
11
|
+
console.log(" npx jops-core init — vendor library and configure index.html");
|
|
12
|
+
console.log(" npx jops-core build — generate production-ready dist/ folder");
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const consumerRoot = process.cwd();
|
|
18
|
+
|
|
19
|
+
function copyDir(src, dest) {
|
|
20
|
+
const SKIP = new Set(["node_modules", "dist", ".git", "package.json", "package-lock.json", ".DS_Store"]);
|
|
21
|
+
const SKIP_EXT = new Set([".md", ".tgz", ".log"]);
|
|
22
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
23
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
24
|
+
if (SKIP.has(entry.name)) continue;
|
|
25
|
+
if (SKIP_EXT.has(path.extname(entry.name).toLowerCase())) continue;
|
|
26
|
+
const srcPath = path.join(src, entry.name);
|
|
27
|
+
const destPath = path.join(dest, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
copyDir(srcPath, destPath);
|
|
30
|
+
} else if (!fs.existsSync(destPath)) {
|
|
31
|
+
fs.copyFileSync(srcPath, destPath);
|
|
32
|
+
console.log(`[jops-core] Created ${path.relative(consumerRoot, destPath)}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── init ────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
if (command === "init") {
|
|
40
|
+
const indexPath = path.join(consumerRoot, "index.html");
|
|
41
|
+
const libDir = path.join(consumerRoot, "lib");
|
|
42
|
+
const vendorDest = path.join(libDir, "jops-core.min.js");
|
|
43
|
+
const vendorSrc = path.join(__dirname, "../lib/jops-core.min.js");
|
|
44
|
+
|
|
45
|
+
if (!fs.existsSync(libDir)) fs.mkdirSync(libDir);
|
|
46
|
+
fs.copyFileSync(vendorSrc, vendorDest);
|
|
47
|
+
console.log("[jops-core] Vendored jops-core.min.js → lib/jops-core.min.js");
|
|
48
|
+
|
|
49
|
+
const IMPORTMAP = ` <script type="importmap">
|
|
50
|
+
{
|
|
51
|
+
"imports": {
|
|
52
|
+
"jops-core": "/lib/jops-core.min.js"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
</script>`;
|
|
56
|
+
|
|
57
|
+
const MODULE_SCRIPT = ` <script type="module" src="/lib/jops-core.min.js"></script>`;
|
|
58
|
+
|
|
59
|
+
const TEMPLATE = `<!doctype html>
|
|
60
|
+
<html lang="en">
|
|
61
|
+
<head>
|
|
62
|
+
<meta charset="UTF-8" />
|
|
63
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
64
|
+
<title>JOPS App</title>
|
|
65
|
+
${IMPORTMAP}
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
${MODULE_SCRIPT}
|
|
69
|
+
</body>
|
|
70
|
+
</html>
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
if (!fs.existsSync(indexPath)) {
|
|
74
|
+
fs.writeFileSync(indexPath, TEMPLATE, "utf8");
|
|
75
|
+
console.log("[jops-core] Created index.html with importmap and module script.");
|
|
76
|
+
} else {
|
|
77
|
+
let html = fs.readFileSync(indexPath, "utf8");
|
|
78
|
+
let changed = false;
|
|
79
|
+
|
|
80
|
+
const hasImportmap = /type=["']importmap["']/.test(html) && html.includes('"jops-core"');
|
|
81
|
+
if (!hasImportmap) {
|
|
82
|
+
html = html.replace("</head>", `${IMPORTMAP}\n </head>`);
|
|
83
|
+
changed = true;
|
|
84
|
+
console.log("[jops-core] Added importmap to index.html.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const hasModuleScript = /src=["'][^"']*jops-core\.min\.js["']/.test(html);
|
|
88
|
+
if (!hasModuleScript) {
|
|
89
|
+
html = html.replace("</body>", `${MODULE_SCRIPT}\n </body>`);
|
|
90
|
+
changed = true;
|
|
91
|
+
console.log("[jops-core] Added module script to index.html.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (changed) {
|
|
95
|
+
fs.writeFileSync(indexPath, html, "utf8");
|
|
96
|
+
} else {
|
|
97
|
+
console.log("[jops-core] index.html already configured.");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// VS Code extension recommendation
|
|
102
|
+
const vscodeSettingsDir = path.join(consumerRoot, ".vscode");
|
|
103
|
+
if (!fs.existsSync(vscodeSettingsDir)) fs.mkdirSync(vscodeSettingsDir);
|
|
104
|
+
|
|
105
|
+
const extensionsJsonPath = path.join(vscodeSettingsDir, "extensions.json");
|
|
106
|
+
const extensionsJson = fs.existsSync(extensionsJsonPath)
|
|
107
|
+
? JSON.parse(fs.readFileSync(extensionsJsonPath, "utf8"))
|
|
108
|
+
: { recommendations: [] };
|
|
109
|
+
if (!extensionsJson.recommendations.includes("bierner.lit-html")) {
|
|
110
|
+
extensionsJson.recommendations.push("bierner.lit-html");
|
|
111
|
+
fs.writeFileSync(extensionsJsonPath, JSON.stringify(extensionsJson, null, 2) + "\n", "utf8");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const settingsJsonPath = path.join(vscodeSettingsDir, "settings.json");
|
|
115
|
+
const settingsJson = fs.existsSync(settingsJsonPath)
|
|
116
|
+
? JSON.parse(fs.readFileSync(settingsJsonPath, "utf8"))
|
|
117
|
+
: {};
|
|
118
|
+
if (!settingsJson["lit-html.tags"]?.includes("layout")) {
|
|
119
|
+
settingsJson["lit-html.tags"] = [...(settingsJson["lit-html.tags"] ?? []), "layout"];
|
|
120
|
+
}
|
|
121
|
+
const jopsTagColor = "#569CD6";
|
|
122
|
+
const rules = settingsJson["editor.tokenColorCustomizations"]?.textMateRules ?? [];
|
|
123
|
+
const hasRule = rules.some(r => r.scope === "invalid.illegal.unrecognized-tag.html");
|
|
124
|
+
if (!hasRule) {
|
|
125
|
+
settingsJson["editor.tokenColorCustomizations"] = {
|
|
126
|
+
...settingsJson["editor.tokenColorCustomizations"],
|
|
127
|
+
textMateRules: [
|
|
128
|
+
...rules,
|
|
129
|
+
{
|
|
130
|
+
scope: "invalid.illegal.unrecognized-tag.html",
|
|
131
|
+
settings: { foreground: jopsTagColor }
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
fs.writeFileSync(settingsJsonPath, JSON.stringify(settingsJson, null, 2) + "\n", "utf8");
|
|
137
|
+
|
|
138
|
+
console.log("[jops-core] VS Code: install the recommended 'lit-html' extension (bierner.lit-html) for HTML highlighting inside layout`` template literals.");
|
|
139
|
+
|
|
140
|
+
// Inject scripts into consumer's package.json
|
|
141
|
+
const pkgPath = path.join(consumerRoot, "package.json");
|
|
142
|
+
if (fs.existsSync(pkgPath)) {
|
|
143
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
144
|
+
let changed = false;
|
|
145
|
+
if (!pkg.scripts?.build) {
|
|
146
|
+
pkg.scripts = { ...pkg.scripts, build: "npx jops-core build" };
|
|
147
|
+
changed = true;
|
|
148
|
+
console.log('[jops-core] Added "build" script to package.json.');
|
|
149
|
+
}
|
|
150
|
+
if (!pkg.scripts?.postinstall) {
|
|
151
|
+
pkg.scripts = { ...pkg.scripts, postinstall: "npx jops-core init" };
|
|
152
|
+
changed = true;
|
|
153
|
+
console.log('[jops-core] Added "postinstall" script to package.json.');
|
|
154
|
+
}
|
|
155
|
+
if (changed) fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Template selection
|
|
159
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
160
|
+
const ask = (q) => new Promise(resolve => rl.question(q, resolve));
|
|
161
|
+
|
|
162
|
+
const choice = await ask(
|
|
163
|
+
"\nCreate JOPS project from existing templates:\n" +
|
|
164
|
+
" 1. Empty JOPS project (basic skeleton for JOPS project architecture)\n" +
|
|
165
|
+
" 2. Hello World JOPS project (an example project utilizing all features)\n" +
|
|
166
|
+
" 3. None (I'll create my own architecture)\n" +
|
|
167
|
+
"Enter choice (1-3): "
|
|
168
|
+
);
|
|
169
|
+
rl.close();
|
|
170
|
+
|
|
171
|
+
const templateSrc = path.join(__dirname, "../templates");
|
|
172
|
+
if (choice.trim() === "1") {
|
|
173
|
+
copyDir(path.join(templateSrc, "empty"), consumerRoot);
|
|
174
|
+
// Add data-app to the module script in index.html
|
|
175
|
+
let html = fs.readFileSync(indexPath, "utf8");
|
|
176
|
+
html = html.replace(
|
|
177
|
+
/(<script[^>]+src=["'][^"']*jops-core\.min\.js["'])([^>]*>)/,
|
|
178
|
+
(_, open, rest) => open.includes("data-app") ? open + rest : open + ' data-app="/src/App.js"' + rest
|
|
179
|
+
);
|
|
180
|
+
fs.writeFileSync(indexPath, html, "utf8");
|
|
181
|
+
console.log("[jops-core] Created empty JOPS project skeleton.");
|
|
182
|
+
} else if (choice.trim() === "2") {
|
|
183
|
+
copyDir(path.join(templateSrc, "hello-world"), consumerRoot);
|
|
184
|
+
let html = fs.readFileSync(indexPath, "utf8");
|
|
185
|
+
html = html.replace(
|
|
186
|
+
/(<script[^>]+src=["'][^"']*jops-core\.min\.js["'])([^>]*>)/,
|
|
187
|
+
(_, open, rest) => open.includes("data-app") ? open + rest : open + ' data-app="/src/App.js"' + rest
|
|
188
|
+
);
|
|
189
|
+
fs.writeFileSync(indexPath, html, "utf8");
|
|
190
|
+
console.log("[jops-core] Created Hello World JOPS project.");
|
|
191
|
+
} else {
|
|
192
|
+
console.log("[jops-core] No template applied. Setup complete.");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── build ───────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
if (command === "build") {
|
|
199
|
+
const BUILD_EXCLUDE = new Set([
|
|
200
|
+
"node_modules", "dist", ".git",
|
|
201
|
+
"package.json", "package-lock.json", ".gitignore", ".npmrc",
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
function buildCopyDir(src, dest) {
|
|
205
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
206
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
207
|
+
if (BUILD_EXCLUDE.has(entry.name)) continue;
|
|
208
|
+
if ([".md", ".tgz", ".log"].includes(path.extname(entry.name).toLowerCase())) continue;
|
|
209
|
+
const srcPath = path.join(src, entry.name);
|
|
210
|
+
const destPath = path.join(dest, entry.name);
|
|
211
|
+
if (entry.isDirectory()) {
|
|
212
|
+
buildCopyDir(srcPath, destPath);
|
|
213
|
+
} else {
|
|
214
|
+
fs.copyFileSync(srcPath, destPath);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const distDir = path.join(consumerRoot, "dist");
|
|
220
|
+
if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true, force: true });
|
|
221
|
+
buildCopyDir(consumerRoot, distDir);
|
|
222
|
+
console.log("[jops-core] Built → dist/");
|
|
223
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
var _=Object.defineProperty;var h=(a,t)=>_(a,"name",{value:t,configurable:!0});(()=>{console.log("JOPS initialized");let a=document.currentScript?.dataset.app??"/src/App.js",t=document.createElement("script");t.type="module",t.textContent=`
|
|
2
|
+
import('${a}')
|
|
3
|
+
.then(m => new m.default())
|
|
4
|
+
.catch(err => console.error("JOPS: App.js not found or failed to load", err));
|
|
5
|
+
`,document.body.appendChild(t)})();var f=class{static{h(this,"RawHTML")}constructor(t){this.html=t}},R=h(a=>new f(a),"raw");function E(a,...t){return a.reduce((e,s,n)=>{if(n>=t.length)return e+s;let o=t[n];return o==null?e+s:o instanceof f?e+s+o.html:e+s+String(o).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},"")}h(E,"layout");var m=class{static{h(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}},p=class a{static{h(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(t);this.layout=e.default}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 m(this.domNode,t)}bindEvents(){if(!this.domNode)return;let t=[...this.domNode.querySelectorAll("[type='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("[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 d=new y(s);await d.render(),await d.onLayout();return}let n=s.getAttribute("layout"),o=s.getAttribute("src");if(!o&&!n)return;let r;if(o){let l=(await import(o)).default;r=new l}else r=new a;this.subViews.push(r),await r.loadLayout(n);let i=document.createElement("template");i.innerHTML=r.layout??"";let c=i.content.cloneNode(!0);if(s.children.length>0){let d=c.firstElementChild??c;Array.from(s.children).forEach(l=>d.appendChild(l))}let u=c.firstElementChild;r.routePath=s.getAttribute("path"),s.replaceWith(c),r.domNode=u,await r.render(),await r?.onLayout?.(),await r?.onResume?.(),r?.bindEvents?.()}))}},b=class a{static{h(this,"EventBus")}static#t={};static subscribe(t,e){a.#t[t]||(a.#t[t]=[]),a.#t[t].push(e)}static send(t,e){a.#t[t]?.forEach(s=>s(e))}static unsubscribe(t,e){a.#t[t]&&(a.#t[t]=a.#t[t].filter(s=>s!==e))}},T=class a{static{h(this,"Store")}static#t=null;static singleton(){return a.#t||(a.#t=new a),a.#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,i)=>(r[i]||(r[i]={}),r[i]),this._state);o[n]=e}clear(){this._state={}}},y=class a extends p{static{h(this,"Router")}static#t=null;static singleton(){return a.#t}constructor(t){super(),a.#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 e of this.subViews)if(e.routePath){let s=e.routePath.slice(1).split("?")[0];this.routes[s]=e}this._navStack=[],this._baseView=null;let t=Object.keys(this.routes)[0];history.replaceState(null,"","#"+t),window.addEventListener("hashchange",()=>{let e=window.location.hash.slice(1),s=e.split("?")[0];if(!(!s||!this.routes[s])){if(this._pendingBack){this._pendingBack=!1;return}this._switchTo(e)}}),window.addEventListener("popstate",()=>{if(this._navStack.length>0){let n=this._navStack.pop(),o=this._navStack.at(-1)??this._baseView;n?.onBackPressed?.(),this._animateTransition(n,o,"back").then(()=>n.destroy());return}this._currentView?.onBackPressed?.();let e=window.location.hash.slice(1),s=e.split("?")[0];s&&this.routes[s]&&(this._pendingBack=!0,this._switchTo(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 n=this.domNode,o=s==="forward",r=n.getBoundingClientRect(),i=t.domNode.getBoundingClientRect(),c=i.left-r.left,u=i.top-r.top,d=i.width,l=n.offsetWidth-c,w=`position:absolute;left:${c}px;top:${u}px;width:${d}px`;n.style.cssText=`position:relative;overflow:hidden;height:${n.offsetHeight}px`,t.domNode.style.cssText=`${w};z-index:${o?1:2}`,t.domNode.hidden=!1,e.domNode.style.cssText=`${w};z-index:${o?2:1}`,e.domNode.hidden=!1;let x=o?e.domNode:t.domNode,S=o?`${l}px`:"0px",V=o?"0px":`${l}px`,N=x.animate([{transform:`translateX(${S})`},{transform:`translateX(${V})`}],{duration:300,easing:"ease",fill:"forwards"});await N.finished,N.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,i=o.top-n.top,c=o.width,u=`position:absolute;left:${r}px;top:${i}px;width:${c}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 d=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([d.finished,l.finished]),d.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,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 i=new r;await i.loadLayout(),i.inflate(),this.domNode.appendChild(i.domNode),history.pushState({jopsNav:!0},"","#"+o+(n?"?"+n:"")),await i.onLayout?.(),await i.onResume?.(),i.bindEvents?.(),this._navStack.push(i),this._animateTransition(this._currentView,i,"forward")}async replace(t,e){this._navStack.forEach(i=>i.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 i=new e;await i.loadLayout(),i.inflate(),this.domNode.appendChild(i.domNode),await i.onLayout?.(),await i.onResume?.(),i.bindEvents?.(),this._currentView&&(this._currentView.domNode.hidden=!0),this._currentView=i,history.replaceState(null,"",r)}else console.error(`JOPS Router: no instance or class found for path "${t}"`)}};export{b as EventBus,y as Router,T as Store,p as View,E as layout,R as raw};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jops-core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A no-bundler, OOP-first SPA framework with Android-style view composition",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": "./lib/jops-core.min.js",
|
|
7
|
+
"browser": "./lib/jops-core.min.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"jops-core": "bin/init.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"lib/jops-core.min.js",
|
|
13
|
+
"bin/init.js",
|
|
14
|
+
"templates/"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "esbuild src/jops-core.js --bundle --format=esm --minify --keep-names --outfile=lib/jops-core.min.js && npm pack"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"spa",
|
|
21
|
+
"framework",
|
|
22
|
+
"oop",
|
|
23
|
+
"no-bundler",
|
|
24
|
+
"router",
|
|
25
|
+
"view",
|
|
26
|
+
"AI friendly SDK"
|
|
27
|
+
],
|
|
28
|
+
"author": "arik@ui-db.com",
|
|
29
|
+
"license": "UNLICENSED",
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"esbuild": "^0.25.12"
|
|
32
|
+
},
|
|
33
|
+
"packageManager": "npm@12.0.2+sha512.b885e890b9418fa1693544d05f53e64f9a73ec194837d4258b15fecdd692347b1dd2a517b1b0cbaf9d31cd8e92c3b70956bd2ecc72833a57b4b3098f5bfa7943"
|
|
34
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<svg viewBox="0 0 400 320" xmlns="http://www.w3.org/2000/svg" font-family="monospace">
|
|
2
|
+
<rect width="400" height="320" fill="#0f1117"/>
|
|
3
|
+
|
|
4
|
+
<!-- Title -->
|
|
5
|
+
<text x="200" y="30" text-anchor="middle" fill="#7c8cf8" font-size="13" font-weight="bold" letter-spacing="2">VIEW HIERARCHY</text>
|
|
6
|
+
|
|
7
|
+
<!-- Root node -->
|
|
8
|
+
<rect x="150" y="50" width="100" height="36" rx="6" fill="#1e2235" stroke="#7c8cf8" stroke-width="1.5"/>
|
|
9
|
+
<text x="200" y="73" text-anchor="middle" fill="#7c8cf8" font-size="12">App</text>
|
|
10
|
+
|
|
11
|
+
<!-- Lines to level 2 -->
|
|
12
|
+
<line x1="200" y1="86" x2="100" y2="120" stroke="#3d4466" stroke-width="1.2"/>
|
|
13
|
+
<line x1="200" y1="86" x2="200" y2="120" stroke="#3d4466" stroke-width="1.2"/>
|
|
14
|
+
<line x1="200" y1="86" x2="300" y2="120" stroke="#3d4466" stroke-width="1.2"/>
|
|
15
|
+
|
|
16
|
+
<!-- Level 2 nodes -->
|
|
17
|
+
<rect x="50" y="120" width="100" height="36" rx="6" fill="#1e2235" stroke="#56cfb2" stroke-width="1.5"/>
|
|
18
|
+
<text x="100" y="143" text-anchor="middle" fill="#56cfb2" font-size="12">Router</text>
|
|
19
|
+
|
|
20
|
+
<rect x="150" y="120" width="100" height="36" rx="6" fill="#1e2235" stroke="#56cfb2" stroke-width="1.5"/>
|
|
21
|
+
<text x="200" y="143" text-anchor="middle" fill="#56cfb2" font-size="12">Header</text>
|
|
22
|
+
|
|
23
|
+
<rect x="250" y="120" width="100" height="36" rx="6" fill="#1e2235" stroke="#56cfb2" stroke-width="1.5"/>
|
|
24
|
+
<text x="300" y="143" text-anchor="middle" fill="#56cfb2" font-size="12">Footer</text>
|
|
25
|
+
|
|
26
|
+
<!-- Lines to level 3 (from Router) -->
|
|
27
|
+
<line x1="100" y1="156" x2="60" y2="192" stroke="#3d4466" stroke-width="1.2"/>
|
|
28
|
+
<line x1="100" y1="156" x2="140" y2="192" stroke="#3d4466" stroke-width="1.2"/>
|
|
29
|
+
|
|
30
|
+
<!-- Level 3 nodes -->
|
|
31
|
+
<rect x="20" y="192" width="80" height="36" rx="6" fill="#1e2235" stroke="#f0a040" stroke-width="1.5"/>
|
|
32
|
+
<text x="60" y="215" text-anchor="middle" fill="#f0a040" font-size="11">HomeView</text>
|
|
33
|
+
|
|
34
|
+
<rect x="110" y="192" width="80" height="36" rx="6" fill="#1e2235" stroke="#f0a040" stroke-width="1.5"/>
|
|
35
|
+
<text x="150" y="215" text-anchor="middle" fill="#f0a040" font-size="11">FormView</text>
|
|
36
|
+
|
|
37
|
+
<!-- Lines to level 4 -->
|
|
38
|
+
<line x1="150" y1="228" x2="130" y2="262" stroke="#3d4466" stroke-width="1.2"/>
|
|
39
|
+
<line x1="150" y1="228" x2="170" y2="262" stroke="#3d4466" stroke-width="1.2"/>
|
|
40
|
+
|
|
41
|
+
<!-- Level 4 nodes -->
|
|
42
|
+
<rect x="100" y="262" width="60" height="30" rx="5" fill="#1e2235" stroke="#f06292" stroke-width="1.5"/>
|
|
43
|
+
<text x="130" y="282" text-anchor="middle" fill="#f06292" font-size="10">Card</text>
|
|
44
|
+
|
|
45
|
+
<rect x="170" y="262" width="60" height="30" rx="5" fill="#1e2235" stroke="#f06292" stroke-width="1.5"/>
|
|
46
|
+
<text x="200" y="282" text-anchor="middle" fill="#f06292" font-size="10">Card</text>
|
|
47
|
+
|
|
48
|
+
<!-- Legend -->
|
|
49
|
+
<rect x="270" y="200" width="10" height="10" rx="2" fill="#7c8cf8"/>
|
|
50
|
+
<text x="285" y="210" fill="#5a6080" font-size="10">App root</text>
|
|
51
|
+
<rect x="270" y="218" width="10" height="10" rx="2" fill="#56cfb2"/>
|
|
52
|
+
<text x="285" y="228" fill="#5a6080" font-size="10">View</text>
|
|
53
|
+
<rect x="270" y="236" width="10" height="10" rx="2" fill="#f0a040"/>
|
|
54
|
+
<text x="285" y="246" fill="#5a6080" font-size="10">Route</text>
|
|
55
|
+
<rect x="270" y="254" width="10" height="10" rx="2" fill="#f06292"/>
|
|
56
|
+
<text x="285" y="264" fill="#5a6080" font-size="10">SubView</text>
|
|
57
|
+
</svg>
|