@okalit/cli 0.1.0 → 0.2.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 +13 -9
- package/lib/cli.js +38 -30
- package/package.json +1 -1
- package/templates/app/@okalit/Okalit.js +163 -89
- package/templates/app/@okalit/channel.js +177 -0
- package/templates/app/@okalit/define-element.js +30 -0
- package/templates/app/@okalit/i18n.js +88 -53
- package/templates/app/@okalit/index.js +8 -10
- package/templates/app/@okalit/mixins.js +106 -0
- package/templates/app/@okalit/performance.js +211 -0
- package/templates/app/@okalit/router-outlet.js +66 -0
- package/templates/app/@okalit/router.js +240 -0
- package/templates/app/@okalit/service.js +318 -23
- package/templates/app/index.html +0 -1
- package/templates/app/package.json +2 -3
- package/templates/app/public/i18n/en.json +47 -1
- package/templates/app/public/i18n/es.json +47 -1
- package/templates/app/src/{app.routes.ts → app.routes.js} +1 -1
- package/templates/app/src/channels/example.channel.js +15 -0
- package/templates/app/src/components/lazy-widget.js +13 -0
- package/templates/app/src/guards/auth.guard.js +17 -0
- package/templates/app/src/layouts/app-layout.js +75 -0
- package/templates/app/src/main-app.js +19 -9
- package/templates/app/src/modules/example/example.module.js +8 -3
- package/templates/app/src/modules/example/example.routes.js +27 -4
- package/templates/app/src/modules/example/pages/detail/example-detail.js +21 -0
- package/templates/app/src/modules/example/pages/home/example-home.js +39 -0
- package/templates/app/src/modules/example/pages/lifecycle/example-lifecycle.js +74 -0
- package/templates/app/src/modules/example/pages/performance/example-performance.js +59 -0
- package/templates/app/src/modules/example/pages/services/example-services.js +80 -0
- package/templates/app/src/services/rickandmorty.service.js +17 -0
- package/templates/app/src/services/user.service.js +33 -0
- package/templates/app/src/styles/global.scss +250 -0
- package/templates/app/src/styles/index.css +11 -2
- package/templates/app/vite.config.js +2 -0
- package/templates/app/@okalit/AppMixin.js +0 -29
- package/templates/app/@okalit/EventBus.js +0 -152
- package/templates/app/@okalit/ModuleMixin.js +0 -7
- package/templates/app/@okalit/OkalitService.js +0 -145
- package/templates/app/@okalit/defineElement.js +0 -65
- package/templates/app/@okalit/idle.js +0 -40
- package/templates/app/@okalit/lazy.js +0 -32
- package/templates/app/@okalit/okalit-router.js +0 -309
- package/templates/app/@okalit/trigger.js +0 -14
- package/templates/app/@okalit/viewport.js +0 -69
- package/templates/app/@okalit/when.js +0 -40
- package/templates/app/public/lit.svg +0 -1
- package/templates/app/src/modules/example/pages/example.page.js +0 -43
- package/templates/app/src/modules/example/pages/example.page.scss +0 -76
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { signal } from 'uhtml';
|
|
2
|
+
import { clearChannelsByScope } from './channel.js';
|
|
3
|
+
|
|
4
|
+
/** @type {Router|null} */
|
|
5
|
+
let instance = null;
|
|
6
|
+
|
|
7
|
+
export class Router {
|
|
8
|
+
constructor(routes = []) {
|
|
9
|
+
if (instance) return instance;
|
|
10
|
+
instance = this;
|
|
11
|
+
|
|
12
|
+
this._routes = routes;
|
|
13
|
+
this._outlets = new Set();
|
|
14
|
+
this._interceptors = [];
|
|
15
|
+
|
|
16
|
+
// Reactive signals for current route state
|
|
17
|
+
this.currentPath = signal(window.location.pathname);
|
|
18
|
+
this.currentRoute = signal(null);
|
|
19
|
+
this.params = signal({});
|
|
20
|
+
this.query = signal({});
|
|
21
|
+
|
|
22
|
+
this._onPopState = this._onPopState.bind(this);
|
|
23
|
+
window.addEventListener('popstate', this._onPopState);
|
|
24
|
+
|
|
25
|
+
// Store pending resolve — outlets will trigger it when they register
|
|
26
|
+
this._pendingPath = window.location.pathname + window.location.search;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static getInstance() {
|
|
30
|
+
return instance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Public API ---
|
|
34
|
+
|
|
35
|
+
navigate(path, { replace = false } = {}) {
|
|
36
|
+
if (path === this.currentPath.value) return;
|
|
37
|
+
if (replace) {
|
|
38
|
+
window.history.replaceState(null, '', path);
|
|
39
|
+
} else {
|
|
40
|
+
window.history.pushState(null, '', path);
|
|
41
|
+
}
|
|
42
|
+
this._resolve(path);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
back() {
|
|
46
|
+
window.history.back();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
addInterceptor(fn) {
|
|
50
|
+
this._interceptors.push(fn);
|
|
51
|
+
return () => {
|
|
52
|
+
this._interceptors = this._interceptors.filter(i => i !== fn);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
registerOutlet(outlet) {
|
|
57
|
+
this._outlets.add(outlet);
|
|
58
|
+
// If there's a pending resolve or an existing route, trigger it
|
|
59
|
+
if (this._pendingPath) {
|
|
60
|
+
const path = this._pendingPath;
|
|
61
|
+
this._pendingPath = null;
|
|
62
|
+
this._resolve(path);
|
|
63
|
+
} else if (this.currentRoute.value) {
|
|
64
|
+
outlet._renderRoute(this.currentRoute.value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
unregisterOutlet(outlet) {
|
|
69
|
+
this._outlets.delete(outlet);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
destroy() {
|
|
73
|
+
window.removeEventListener('popstate', this._onPopState);
|
|
74
|
+
instance = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Internal ---
|
|
78
|
+
|
|
79
|
+
_onPopState() {
|
|
80
|
+
this._resolve(window.location.pathname + window.location.search);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async _resolve(fullPath) {
|
|
84
|
+
const [pathname, search] = fullPath.split('?');
|
|
85
|
+
const path = pathname || '/';
|
|
86
|
+
const query = Object.fromEntries(new URLSearchParams(search || ''));
|
|
87
|
+
|
|
88
|
+
const match = this._matchRoute(this._routes, path);
|
|
89
|
+
if (!match) {
|
|
90
|
+
console.warn(`[okalit-router] No route matched: ${path}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Run guards BEFORE loading anything
|
|
95
|
+
const guardsPassed = await this._runGuards(match.guards, path, match);
|
|
96
|
+
if (!guardsPassed) return;
|
|
97
|
+
|
|
98
|
+
// Run interceptors
|
|
99
|
+
for (const interceptor of this._interceptors) {
|
|
100
|
+
const result = await interceptor(match, path);
|
|
101
|
+
if (result === false) return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Update reactive state
|
|
105
|
+
this.currentPath.value = path;
|
|
106
|
+
this.params.value = match.params;
|
|
107
|
+
this.query.value = query;
|
|
108
|
+
|
|
109
|
+
// Determine what changed and clear scoped channels
|
|
110
|
+
const prevRoute = this.currentRoute.value;
|
|
111
|
+
if (prevRoute) {
|
|
112
|
+
const prevPage = prevRoute.chain[prevRoute.chain.length - 1]?.component;
|
|
113
|
+
const newPage = match.chain[match.chain.length - 1]?.component;
|
|
114
|
+
const prevModule = prevRoute.chain[0]?.component;
|
|
115
|
+
const newModule = match.chain[0]?.component;
|
|
116
|
+
|
|
117
|
+
if (prevPage !== newPage) clearChannelsByScope('page');
|
|
118
|
+
if (prevModule !== newModule) clearChannelsByScope('module');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.currentRoute.value = match;
|
|
122
|
+
|
|
123
|
+
// Notify all outlets
|
|
124
|
+
for (const outlet of this._outlets) {
|
|
125
|
+
outlet._renderRoute(match);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async _runGuards(guards, path, match) {
|
|
130
|
+
if (!guards || !guards.length) return true;
|
|
131
|
+
|
|
132
|
+
for (const guard of guards) {
|
|
133
|
+
const result = await guard({ path, params: match.params, route: match });
|
|
134
|
+
if (result === false) return false;
|
|
135
|
+
if (typeof result === 'string') {
|
|
136
|
+
// Guard returned a redirect path
|
|
137
|
+
this.navigate(result, { replace: true });
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Match a path against the route tree.
|
|
146
|
+
* Returns a flat match object with the chain of matched routes.
|
|
147
|
+
*/
|
|
148
|
+
_matchRoute(routes, path, basePath = '', parentGuards = []) {
|
|
149
|
+
for (const route of routes) {
|
|
150
|
+
const fullPattern = normalizePath(basePath + '/' + route.path);
|
|
151
|
+
|
|
152
|
+
// For routes with children, use prefix matching
|
|
153
|
+
// For leaf routes, use exact matching
|
|
154
|
+
const hasChildren = route.children && route.children.length;
|
|
155
|
+
const match = hasChildren
|
|
156
|
+
? matchPrefix(fullPattern, path)
|
|
157
|
+
: matchPath(fullPattern, path);
|
|
158
|
+
|
|
159
|
+
if (match) {
|
|
160
|
+
const guards = [...parentGuards, ...(route.guards || [])];
|
|
161
|
+
const result = {
|
|
162
|
+
route,
|
|
163
|
+
params: match.params,
|
|
164
|
+
guards,
|
|
165
|
+
chain: [route],
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// If route has children, try to match deeper
|
|
169
|
+
if (route.children && route.children.length) {
|
|
170
|
+
const childMatch = this._matchRoute(route.children, path, fullPattern, guards);
|
|
171
|
+
if (childMatch) {
|
|
172
|
+
childMatch.chain = [route, ...childMatch.chain];
|
|
173
|
+
return childMatch;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Exact match or this route is a leaf
|
|
178
|
+
if (match.exact || !route.children) {
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Path utilities ---
|
|
188
|
+
|
|
189
|
+
function normalizePath(path) {
|
|
190
|
+
return '/' + path.split('/').filter(Boolean).join('/');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Match a pattern like /users/:id against a path like /users/42
|
|
195
|
+
* Returns { params, exact } or null
|
|
196
|
+
*/
|
|
197
|
+
function matchPath(pattern, path) {
|
|
198
|
+
const patternParts = pattern.split('/').filter(Boolean);
|
|
199
|
+
const pathParts = path.split('/').filter(Boolean);
|
|
200
|
+
|
|
201
|
+
// Exact match requires same length
|
|
202
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
203
|
+
|
|
204
|
+
const params = {};
|
|
205
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
206
|
+
if (patternParts[i].startsWith(':')) {
|
|
207
|
+
params[patternParts[i].slice(1)] = pathParts[i];
|
|
208
|
+
} else if (patternParts[i] !== pathParts[i]) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { params, exact: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Prefix match: pattern must match the beginning of the path
|
|
218
|
+
*/
|
|
219
|
+
function matchPrefix(pattern, path) {
|
|
220
|
+
const patternParts = pattern.split('/').filter(Boolean);
|
|
221
|
+
const pathParts = path.split('/').filter(Boolean);
|
|
222
|
+
|
|
223
|
+
if (pathParts.length < patternParts.length) return null;
|
|
224
|
+
|
|
225
|
+
const params = {};
|
|
226
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
227
|
+
if (patternParts[i].startsWith(':')) {
|
|
228
|
+
params[patternParts[i].slice(1)] = pathParts[i];
|
|
229
|
+
} else if (patternParts[i] !== pathParts[i]) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { params, exact: patternParts.length === pathParts.length };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Navigation helper for use outside components
|
|
238
|
+
export function navigate(path, options) {
|
|
239
|
+
Router.getInstance()?.navigate(path, options);
|
|
240
|
+
}
|
|
@@ -1,33 +1,328 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// ── RequestControl ─────────────────────────────────────────────
|
|
2
|
+
// Wraps a Promise (typically from fetch) and exposes a declarative
|
|
3
|
+
// fire() API for loading / success / error / finish callbacks,
|
|
4
|
+
// while still being await-able via then().
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
export class RequestControl {
|
|
7
|
+
#promise;
|
|
5
8
|
|
|
9
|
+
constructor(promise) {
|
|
10
|
+
this.#promise = promise;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Declarative async flow handler.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} callbacks
|
|
17
|
+
* @param {(loading: boolean) => void} [callbacks.onLoading]
|
|
18
|
+
* @param {(data: any) => void} [callbacks.onSuccess]
|
|
19
|
+
* @param {(error: any) => void} [callbacks.onError]
|
|
20
|
+
* @param {() => void} [callbacks.onFinish]
|
|
21
|
+
*/
|
|
22
|
+
fire({ onLoading, onSuccess, onError, onFinish } = {}) {
|
|
23
|
+
onLoading?.(true);
|
|
24
|
+
|
|
25
|
+
this.#promise
|
|
26
|
+
.then((data) => {
|
|
27
|
+
onSuccess?.(data);
|
|
28
|
+
})
|
|
29
|
+
.catch((err) => {
|
|
30
|
+
onError?.(err);
|
|
31
|
+
})
|
|
32
|
+
.finally(() => {
|
|
33
|
+
onLoading?.(false);
|
|
34
|
+
onFinish?.();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Allow `await service.getX()` without calling fire(). */
|
|
39
|
+
then(resolve, reject) {
|
|
40
|
+
return this.#promise.then(resolve, reject);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
catch(reject) {
|
|
44
|
+
return this.#promise.catch(reject);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
finally(cb) {
|
|
48
|
+
return this.#promise.finally(cb);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Service registry ───────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const _registry = new Map();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decorator — registers the class as a singleton service.
|
|
58
|
+
*
|
|
59
|
+
* Usage:
|
|
60
|
+
* @service('user')
|
|
61
|
+
* class UserService extends OkalitService { … }
|
|
62
|
+
*/
|
|
6
63
|
export function service(name) {
|
|
7
|
-
return function (
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
64
|
+
return function (cls, context) {
|
|
65
|
+
context.addInitializer(function () {
|
|
66
|
+
if (!_registry.has(name)) {
|
|
67
|
+
_registry.set(name, new cls());
|
|
68
|
+
}
|
|
69
|
+
});
|
|
13
70
|
};
|
|
14
71
|
}
|
|
15
72
|
|
|
16
|
-
|
|
17
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Retrieve a registered service singleton by name.
|
|
75
|
+
*
|
|
76
|
+
* Usage:
|
|
77
|
+
* userApi = inject('user');
|
|
78
|
+
*/
|
|
79
|
+
export function inject(name) {
|
|
80
|
+
const instance = _registry.get(name);
|
|
81
|
+
if (!instance) {
|
|
82
|
+
throw new Error(`[inject] Service "${name}" not found. Make sure the file is imported and @service("${name}") is applied.`);
|
|
83
|
+
}
|
|
84
|
+
return instance;
|
|
18
85
|
}
|
|
19
86
|
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
87
|
+
// ── OkalitService base class ───────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export class OkalitService {
|
|
90
|
+
#baseUrl = '';
|
|
91
|
+
#headers = { 'Content-Type': 'application/json' };
|
|
92
|
+
#cache = new Map();
|
|
93
|
+
#cacheEnabled = false;
|
|
94
|
+
#cacheTTL = 0; // 0 = no expiry
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Configure the service instance.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} opts
|
|
100
|
+
* @param {string} [opts.baseUrl]
|
|
101
|
+
* @param {Record<string, string>} [opts.headers]
|
|
102
|
+
* @param {boolean} [opts.cache] — enable/disable in-memory cache (default: false)
|
|
103
|
+
* @param {number} [opts.cacheTTL] — cache lifetime in ms (0 = forever until clearCache)
|
|
104
|
+
*/
|
|
105
|
+
configure({ baseUrl, headers, cache, cacheTTL } = {}) {
|
|
106
|
+
if (baseUrl !== undefined) this.#baseUrl = baseUrl.replace(/\/+$/, '');
|
|
107
|
+
if (headers !== undefined) this.#headers = { ...this.#headers, ...headers };
|
|
108
|
+
if (cache !== undefined) this.#cacheEnabled = cache;
|
|
109
|
+
if (cacheTTL !== undefined) this.#cacheTTL = cacheTTL;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── HTTP helpers ───────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* GET request (cacheable).
|
|
116
|
+
* @param {string} path
|
|
117
|
+
* @param {Record<string, string>} [params] — query-string params
|
|
118
|
+
* @returns {RequestControl}
|
|
119
|
+
*/
|
|
120
|
+
get(path, params) {
|
|
121
|
+
let url = `${this.#baseUrl}${path}`;
|
|
122
|
+
if (params) {
|
|
123
|
+
const qs = new URLSearchParams(params).toString();
|
|
124
|
+
if (qs) url += `?${qs}`;
|
|
125
|
+
}
|
|
126
|
+
return this.#request(url, { method: 'GET' }, true);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* POST request.
|
|
131
|
+
* @param {string} path
|
|
132
|
+
* @param {any} body
|
|
133
|
+
* @returns {RequestControl}
|
|
134
|
+
*/
|
|
135
|
+
post(path, body) {
|
|
136
|
+
return this.#request(`${this.#baseUrl}${path}`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
body: JSON.stringify(body),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* PUT request.
|
|
144
|
+
* @param {string} path
|
|
145
|
+
* @param {any} body
|
|
146
|
+
* @returns {RequestControl}
|
|
147
|
+
*/
|
|
148
|
+
put(path, body) {
|
|
149
|
+
return this.#request(`${this.#baseUrl}${path}`, {
|
|
150
|
+
method: 'PUT',
|
|
151
|
+
body: JSON.stringify(body),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* DELETE request.
|
|
157
|
+
* @param {string} path
|
|
158
|
+
* @returns {RequestControl}
|
|
159
|
+
*/
|
|
160
|
+
delete(path) {
|
|
161
|
+
return this.#request(`${this.#baseUrl}${path}`, {
|
|
162
|
+
method: 'DELETE',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Clear the in-memory cache (all entries or a specific path). */
|
|
167
|
+
clearCache(path) {
|
|
168
|
+
if (path) {
|
|
169
|
+
const url = `${this.#baseUrl}${path}`;
|
|
170
|
+
this.#cache.delete(url);
|
|
171
|
+
} else {
|
|
172
|
+
this.#cache.clear();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Internal ───────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
#request(url, options, cacheable = false) {
|
|
179
|
+
// Check cache for GET requests
|
|
180
|
+
if (cacheable && this.#cacheEnabled) {
|
|
181
|
+
const cached = this.#cache.get(url);
|
|
182
|
+
if (cached) {
|
|
183
|
+
const expired = this.#cacheTTL > 0 && (Date.now() - cached.time > this.#cacheTTL);
|
|
184
|
+
if (!expired) {
|
|
185
|
+
return new RequestControl(Promise.resolve(structuredClone(cached.data)));
|
|
186
|
+
}
|
|
187
|
+
this.#cache.delete(url);
|
|
188
|
+
}
|
|
31
189
|
}
|
|
32
|
-
|
|
190
|
+
|
|
191
|
+
const promise = fetch(url, {
|
|
192
|
+
...options,
|
|
193
|
+
headers: { ...this.#headers },
|
|
194
|
+
}).then(async (res) => {
|
|
195
|
+
if (!res.ok) {
|
|
196
|
+
let errorBody;
|
|
197
|
+
try {
|
|
198
|
+
errorBody = await res.json();
|
|
199
|
+
} catch {
|
|
200
|
+
errorBody = { message: res.statusText };
|
|
201
|
+
}
|
|
202
|
+
throw { status: res.status, statusText: res.statusText, body: errorBody };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Handle 204 No Content
|
|
206
|
+
const text = await res.text();
|
|
207
|
+
if (!text) return null;
|
|
208
|
+
|
|
209
|
+
let data;
|
|
210
|
+
try {
|
|
211
|
+
data = JSON.parse(text);
|
|
212
|
+
} catch {
|
|
213
|
+
data = text;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Store in cache
|
|
217
|
+
if (cacheable && this.#cacheEnabled) {
|
|
218
|
+
this.#cache.set(url, { data, time: Date.now() });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return data;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return new RequestControl(promise);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── OkalitGraphqlService ───────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export class OkalitGraphqlService {
|
|
231
|
+
#endpoint = '';
|
|
232
|
+
#headers = { 'Content-Type': 'application/json' };
|
|
233
|
+
#cache = new Map();
|
|
234
|
+
#cacheEnabled = false;
|
|
235
|
+
#cacheTTL = 0;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {Object} opts
|
|
239
|
+
* @param {string} [opts.endpoint] — GraphQL endpoint URL
|
|
240
|
+
* @param {Record<string, string>} [opts.headers]
|
|
241
|
+
* @param {boolean} [opts.cache] — cache query results (default: false)
|
|
242
|
+
* @param {number} [opts.cacheTTL] — cache lifetime in ms
|
|
243
|
+
*/
|
|
244
|
+
configure({ endpoint, headers, cache, cacheTTL } = {}) {
|
|
245
|
+
if (endpoint !== undefined) this.#endpoint = endpoint.replace(/\/+$/, '');
|
|
246
|
+
if (headers !== undefined) this.#headers = { ...this.#headers, ...headers };
|
|
247
|
+
if (cache !== undefined) this.#cacheEnabled = cache;
|
|
248
|
+
if (cacheTTL !== undefined) this.#cacheTTL = cacheTTL;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Execute a GraphQL query.
|
|
253
|
+
* @param {string} queryString
|
|
254
|
+
* @param {Record<string, any>} [variables]
|
|
255
|
+
* @returns {RequestControl}
|
|
256
|
+
*/
|
|
257
|
+
query(queryString, variables = {}) {
|
|
258
|
+
return this.#execute(queryString, variables);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Alias for query — semantically marks a mutation.
|
|
263
|
+
* @param {string} mutationString
|
|
264
|
+
* @param {Record<string, any>} [variables]
|
|
265
|
+
* @returns {RequestControl}
|
|
266
|
+
*/
|
|
267
|
+
mutate(mutationString, variables = {}) {
|
|
268
|
+
return this.#execute(mutationString, variables);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Clear cached queries. Pass a query string to clear a specific one. */
|
|
272
|
+
clearCache(queryString) {
|
|
273
|
+
if (queryString) {
|
|
274
|
+
this.#cache.delete(queryString);
|
|
275
|
+
} else {
|
|
276
|
+
this.#cache.clear();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#execute(queryString, variables) {
|
|
281
|
+
// Cache check (keyed by query + variables)
|
|
282
|
+
const cacheKey = queryString + JSON.stringify(variables);
|
|
283
|
+
if (this.#cacheEnabled) {
|
|
284
|
+
const cached = this.#cache.get(cacheKey);
|
|
285
|
+
if (cached) {
|
|
286
|
+
const expired = this.#cacheTTL > 0 && (Date.now() - cached.time > this.#cacheTTL);
|
|
287
|
+
if (!expired) {
|
|
288
|
+
return new RequestControl(Promise.resolve(structuredClone(cached.data)));
|
|
289
|
+
}
|
|
290
|
+
this.#cache.delete(cacheKey);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const promise = fetch(this.#endpoint, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: { ...this.#headers },
|
|
297
|
+
body: JSON.stringify({ query: queryString, variables }),
|
|
298
|
+
}).then(async (res) => {
|
|
299
|
+
// Handle HTTP-level errors (network, 500, etc.)
|
|
300
|
+
if (!res.ok) {
|
|
301
|
+
let errorBody;
|
|
302
|
+
try {
|
|
303
|
+
errorBody = await res.json();
|
|
304
|
+
} catch {
|
|
305
|
+
errorBody = { message: res.statusText };
|
|
306
|
+
}
|
|
307
|
+
throw { status: res.status, statusText: res.statusText, body: errorBody };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const json = await res.json();
|
|
311
|
+
|
|
312
|
+
// GraphQL can return 200 OK with errors in body
|
|
313
|
+
if (json.errors) {
|
|
314
|
+
throw { graphql: true, errors: json.errors, data: json.data ?? null };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const data = json.data;
|
|
318
|
+
|
|
319
|
+
if (this.#cacheEnabled) {
|
|
320
|
+
this.#cache.set(cacheKey, { data, time: Date.now() });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return data;
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return new RequestControl(promise);
|
|
327
|
+
}
|
|
33
328
|
}
|
package/templates/app/index.html
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
<html lang="es">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<link rel="icon" type="image/svg+xml" href="/lit.svg" />
|
|
6
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
6
|
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
|
8
7
|
<title>Okalit App</title>
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
"preview": "vite preview"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"
|
|
13
|
-
"lit": "^3.3.1"
|
|
12
|
+
"uhtml": "^5.0.9"
|
|
14
13
|
},
|
|
15
14
|
"devDependencies": {
|
|
16
15
|
"@babel/core": "^7.29.0",
|
|
@@ -20,4 +19,4 @@
|
|
|
20
19
|
"vite": "^7.3.1",
|
|
21
20
|
"vite-plugin-babel": "^1.5.1"
|
|
22
21
|
}
|
|
23
|
-
}
|
|
22
|
+
}
|
|
@@ -1,3 +1,49 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
2
|
+
"APP": {
|
|
3
|
+
"TITLE": "Okalit Starter",
|
|
4
|
+
"SUBTITLE": "A modern web component framework"
|
|
5
|
+
},
|
|
6
|
+
"NAV": {
|
|
7
|
+
"HOME": "Home",
|
|
8
|
+
"LIFECYCLE": "Lifecycle",
|
|
9
|
+
"SERVICES": "Services",
|
|
10
|
+
"PERFORMANCE": "Performance",
|
|
11
|
+
"DETAIL": "Detail {{ id }}"
|
|
12
|
+
},
|
|
13
|
+
"HOME": {
|
|
14
|
+
"HEADING": "Dashboard",
|
|
15
|
+
"COUNTER": "Shared Counter",
|
|
16
|
+
"COUNTER_HINT": "Counter must be > 2 to access the detail page (guard demo).",
|
|
17
|
+
"RESET": "Reset",
|
|
18
|
+
"GO_DETAIL": "Go to Detail {{ id }}",
|
|
19
|
+
"GO_LIFECYCLE": "Lifecycle Demo",
|
|
20
|
+
"GO_SERVICES": "Services Demo",
|
|
21
|
+
"GO_PERF": "Performance Demo"
|
|
22
|
+
},
|
|
23
|
+
"LIFECYCLE": {
|
|
24
|
+
"HEADING": "Lifecycle Hooks",
|
|
25
|
+
"LOG": "Event log",
|
|
26
|
+
"CLEAR": "Clear",
|
|
27
|
+
"CHANGE_PROP": "Change name prop"
|
|
28
|
+
},
|
|
29
|
+
"SERVICES": {
|
|
30
|
+
"HEADING": "Services & GraphQL",
|
|
31
|
+
"REST_TITLE": "REST — JSONPlaceholder Users",
|
|
32
|
+
"GQL_TITLE": "GraphQL — Rick & Morty Characters",
|
|
33
|
+
"LOADING": "Loading…",
|
|
34
|
+
"ERROR": "Error loading data."
|
|
35
|
+
},
|
|
36
|
+
"PERF": {
|
|
37
|
+
"HEADING": "Performance Components",
|
|
38
|
+
"IDLE_LABEL": "Loaded via <o-idle> (requestIdleCallback)",
|
|
39
|
+
"WHEN_LABEL": "Loaded via <o-when> (condition)",
|
|
40
|
+
"VIEWPORT_LABEL": "Loaded via <o-viewport> (scroll down)",
|
|
41
|
+
"TOGGLE": "Toggle condition",
|
|
42
|
+
"SCROLL_HINT": "Scroll down to trigger <o-viewport>…"
|
|
43
|
+
},
|
|
44
|
+
"DETAIL": {
|
|
45
|
+
"HEADING": "Detail Page",
|
|
46
|
+
"VIEWING": "Viewing item",
|
|
47
|
+
"BACK": "Back to Home"
|
|
48
|
+
}
|
|
3
49
|
}
|