@okalit/cli 0.1.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 +32 -0
- package/bin/okalit-cli.js +8 -0
- package/lib/cli.js +515 -0
- package/package.json +30 -0
- package/templates/app/@okalit/AppMixin.js +29 -0
- package/templates/app/@okalit/EventBus.js +152 -0
- package/templates/app/@okalit/ModuleMixin.js +7 -0
- package/templates/app/@okalit/Okalit.js +129 -0
- package/templates/app/@okalit/OkalitService.js +145 -0
- package/templates/app/@okalit/defineElement.js +65 -0
- package/templates/app/@okalit/i18n.js +89 -0
- package/templates/app/@okalit/idle.js +40 -0
- package/templates/app/@okalit/index.js +10 -0
- package/templates/app/@okalit/lazy.js +32 -0
- package/templates/app/@okalit/okalit-router.js +309 -0
- package/templates/app/@okalit/service.js +33 -0
- package/templates/app/@okalit/trigger.js +14 -0
- package/templates/app/@okalit/viewport.js +69 -0
- package/templates/app/@okalit/when.js +40 -0
- package/templates/app/babel.config.json +5 -0
- package/templates/app/index.html +15 -0
- package/templates/app/package.json +23 -0
- package/templates/app/public/i18n/en.json +3 -0
- package/templates/app/public/i18n/es.json +3 -0
- package/templates/app/public/lit.svg +1 -0
- package/templates/app/src/app.routes.ts +10 -0
- package/templates/app/src/main-app.js +13 -0
- package/templates/app/src/modules/example/example.module.js +4 -0
- package/templates/app/src/modules/example/example.routes.js +7 -0
- package/templates/app/src/modules/example/pages/example.page.js +43 -0
- package/templates/app/src/modules/example/pages/example.page.scss +76 -0
- package/templates/app/src/styles/global.scss +0 -0
- package/templates/app/src/styles/index.css +4 -0
- package/templates/app/vite.config.js +19 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Prevents crashes from tampered or corrupted storage values
|
|
2
|
+
function safeJsonParse(str, fallback = undefined) {
|
|
3
|
+
try {
|
|
4
|
+
return JSON.parse(str);
|
|
5
|
+
} catch {
|
|
6
|
+
return fallback;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class EventBusImpl {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.listeners = Object.create(null);
|
|
13
|
+
this.triggers = Object.create(null);
|
|
14
|
+
this.persistTypes = {
|
|
15
|
+
memory: null,
|
|
16
|
+
session: window.sessionStorage,
|
|
17
|
+
local: window.localStorage
|
|
18
|
+
};
|
|
19
|
+
this.memoryStore = Object.create(null);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_readPersistedValue(event, persist = 'memory') {
|
|
23
|
+
if (persist === 'memory') {
|
|
24
|
+
const found = event in this.memoryStore;
|
|
25
|
+
return {
|
|
26
|
+
found,
|
|
27
|
+
value: found ? this.memoryStore[event] : undefined,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const storage = this.persistTypes[persist];
|
|
32
|
+
if (!storage) {
|
|
33
|
+
return {
|
|
34
|
+
found: false,
|
|
35
|
+
value: undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const stored = storage.getItem(`okalit:bus:${event}`);
|
|
40
|
+
return {
|
|
41
|
+
found: stored !== null,
|
|
42
|
+
value: stored !== null ? safeJsonParse(stored) : undefined,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Validate event name to prevent accidental misuse
|
|
47
|
+
_validateEvent(event) {
|
|
48
|
+
if (typeof event !== 'string' || event.trim() === '') {
|
|
49
|
+
throw new Error(`EventBus: invalid event name "${event}"`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Remove a specific channel from memory, session or local storage
|
|
54
|
+
remove(event, { persist = 'memory' } = {}) {
|
|
55
|
+
this._validateEvent(event);
|
|
56
|
+
if (persist === 'memory') {
|
|
57
|
+
delete this.memoryStore[event];
|
|
58
|
+
} else {
|
|
59
|
+
const storage = this.persistTypes[persist];
|
|
60
|
+
if (storage) {
|
|
61
|
+
storage.removeItem(`okalit:bus:${event}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Clear all channels of a given persistence type
|
|
67
|
+
clearAll({ persist = 'memory' } = {}) {
|
|
68
|
+
if (persist === 'memory') {
|
|
69
|
+
this.memoryStore = Object.create(null);
|
|
70
|
+
} else {
|
|
71
|
+
const storage = this.persistTypes[persist];
|
|
72
|
+
if (storage) {
|
|
73
|
+
// Collect keys first to avoid mutating storage during iteration
|
|
74
|
+
const keys = [];
|
|
75
|
+
for (let i = 0; i < storage.length; i++) {
|
|
76
|
+
const k = storage.key(i);
|
|
77
|
+
if (k && k.startsWith('okalit:bus:')) keys.push(k);
|
|
78
|
+
}
|
|
79
|
+
keys.forEach(k => storage.removeItem(k));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Emit a stateful event (persists value and notifies subscribers)
|
|
85
|
+
emit(event, data, { persist = 'memory' } = {}) {
|
|
86
|
+
this._validateEvent(event);
|
|
87
|
+
if (persist !== 'memory') {
|
|
88
|
+
const storage = this.persistTypes[persist];
|
|
89
|
+
if (storage) {
|
|
90
|
+
storage.setItem(`okalit:bus:${event}`, JSON.stringify(data));
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
this.memoryStore[event] = data;
|
|
94
|
+
}
|
|
95
|
+
// Notify all subscribers
|
|
96
|
+
const cbs = this.listeners[event];
|
|
97
|
+
if (cbs) {
|
|
98
|
+
for (let i = 0; i < cbs.length; i++) cbs[i](data);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get(event, { persist = 'memory', fallback } = {}) {
|
|
103
|
+
this._validateEvent(event);
|
|
104
|
+
const { found, value } = this._readPersistedValue(event, persist);
|
|
105
|
+
|
|
106
|
+
if (!found || value === undefined) {
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Trigger a stateless (ephemeral) event — no persistence
|
|
114
|
+
trigger(event, data) {
|
|
115
|
+
this._validateEvent(event);
|
|
116
|
+
const cbs = this.triggers[event];
|
|
117
|
+
if (cbs) {
|
|
118
|
+
for (let i = 0; i < cbs.length; i++) cbs[i](data);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Subscribe to a stateful channel; delivers persisted value immediately if available
|
|
123
|
+
on(event, cb, { persist = 'memory', immediate = true } = {}) {
|
|
124
|
+
this._validateEvent(event);
|
|
125
|
+
if (!this.listeners[event]) this.listeners[event] = [];
|
|
126
|
+
this.listeners[event].push(cb);
|
|
127
|
+
|
|
128
|
+
// Deliver persisted value immediately on subscribe
|
|
129
|
+
if (immediate) {
|
|
130
|
+
const { found, value } = this._readPersistedValue(event, persist);
|
|
131
|
+
if (found && value !== undefined) cb(value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Return unsubscribe function
|
|
135
|
+
return () => {
|
|
136
|
+
this.listeners[event] = (this.listeners[event] || []).filter(fn => fn !== cb);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Subscribe to ephemeral (stateless) events
|
|
141
|
+
listen(event, cb) {
|
|
142
|
+
this._validateEvent(event);
|
|
143
|
+
if (!this.triggers[event]) this.triggers[event] = [];
|
|
144
|
+
this.triggers[event].push(cb);
|
|
145
|
+
// Return unsubscribe function
|
|
146
|
+
return () => {
|
|
147
|
+
this.triggers[event] = (this.triggers[event] || []).filter(fn => fn !== cb);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const EventBus = new EventBusImpl();
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
|
|
2
|
+
import { LitElement } from 'lit';
|
|
3
|
+
import { EventBus } from './EventBus.js';
|
|
4
|
+
import { OkalitI18n } from './i18n.js';
|
|
5
|
+
import {signal, SignalWatcher} from '@lit-labs/signals';
|
|
6
|
+
|
|
7
|
+
export class Okalit extends SignalWatcher(LitElement) {
|
|
8
|
+
constructor() {
|
|
9
|
+
super();
|
|
10
|
+
|
|
11
|
+
this._okalit_channel_unsubs = [];
|
|
12
|
+
this._okalit_listen_unsubs = [];
|
|
13
|
+
|
|
14
|
+
const props = this.constructor.properties || {};
|
|
15
|
+
for (const [key, opts] of Object.entries(props)) {
|
|
16
|
+
if ('value' in opts && this[key] === undefined) {
|
|
17
|
+
this[key] = opts.value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
connectedCallback() {
|
|
23
|
+
super.connectedCallback();
|
|
24
|
+
|
|
25
|
+
if (typeof this.okalitConnections === 'function') {
|
|
26
|
+
this.okalitConnections();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
disconnectedCallback() {
|
|
31
|
+
super.disconnectedCallback();
|
|
32
|
+
|
|
33
|
+
if (this._okalit_channel_unsubs.length > 0) {
|
|
34
|
+
this._okalit_channel_unsubs.forEach(unsub => unsub());
|
|
35
|
+
this._okalit_channel_unsubs = [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (this._okalit_listen_unsubs.length > 0) {
|
|
39
|
+
this._okalit_listen_unsubs.forEach(unsub => unsub());
|
|
40
|
+
this._okalit_listen_unsubs = [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Clean up @trigger decorator subscriptions
|
|
44
|
+
if (this._okalit_trigger_unsubs && this._okalit_trigger_unsubs.length > 0) {
|
|
45
|
+
this._okalit_trigger_unsubs.forEach(unsub => unsub());
|
|
46
|
+
this._okalit_trigger_unsubs = [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
output(eventName, detail) {
|
|
51
|
+
this.dispatchEvent(new CustomEvent(eventName, {
|
|
52
|
+
detail,
|
|
53
|
+
bubbles: true,
|
|
54
|
+
composed: true
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
emit(event, data, options) {
|
|
59
|
+
EventBus.emit(event, data, options);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
trigger(event, data) {
|
|
63
|
+
EventBus.trigger(event, data);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
navigate(path, args) {
|
|
67
|
+
EventBus.trigger('okalit-route:navigate', { path, args });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Called by the router to inject route + query params.
|
|
71
|
+
// Coerces values to the types declared in defineElement({ params }).
|
|
72
|
+
set routeParams(obj) {
|
|
73
|
+
if (!obj || typeof obj !== 'object') return;
|
|
74
|
+
const schema = this.constructor.__okalitParams;
|
|
75
|
+
for (const [key, raw] of Object.entries(obj)) {
|
|
76
|
+
if (schema && schema[key]) {
|
|
77
|
+
const typeCtor = schema[key].type;
|
|
78
|
+
if (typeCtor === Number) this[key] = Number(raw);
|
|
79
|
+
else if (typeCtor === Boolean) this[key] = raw === 'true' || raw === '1' || raw === true;
|
|
80
|
+
else this[key] = String(raw);
|
|
81
|
+
} else {
|
|
82
|
+
// No schema — assign as-is (string from URL)
|
|
83
|
+
this[key] = raw;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
listen(event, callback) {
|
|
89
|
+
const unsub = EventBus.listen(event, callback.bind(this));
|
|
90
|
+
this._okalit_listen_unsubs.push(unsub);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
t(key, params) {
|
|
94
|
+
return OkalitI18n.t(key, params);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
channel(event, { persist = 'memory', initialValue, onValue } = {}) {
|
|
98
|
+
const currentValue = EventBus.get(event, { persist, fallback: initialValue });
|
|
99
|
+
const s = signal(currentValue);
|
|
100
|
+
|
|
101
|
+
const handleValue = (val) => {
|
|
102
|
+
s.set(val);
|
|
103
|
+
if (typeof onValue === 'function') {
|
|
104
|
+
onValue.call(this, val);
|
|
105
|
+
}
|
|
106
|
+
this.requestUpdate();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Subscribe to channel changes
|
|
110
|
+
const unsub = EventBus.on(event, handleValue, {
|
|
111
|
+
persist,
|
|
112
|
+
immediate: false,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
this._okalit_channel_unsubs.push(unsub);
|
|
116
|
+
|
|
117
|
+
if (typeof onValue === 'function') {
|
|
118
|
+
onValue.call(this, currentValue);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
get: () => s.get(),
|
|
123
|
+
set: (v) => {
|
|
124
|
+
s.set(v);
|
|
125
|
+
EventBus.emit(event, v, { persist });
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { EventBus } from './EventBus.js';
|
|
2
|
+
|
|
3
|
+
export class OkalitService {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.baseUrl = '';
|
|
6
|
+
this.headers = {};
|
|
7
|
+
this.__cache = new Map();
|
|
8
|
+
this.__debounceTimers = new Map();
|
|
9
|
+
this.__activeRequests = new Map();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
_makeCacheKey(path, params) {
|
|
13
|
+
let key = path;
|
|
14
|
+
if (params && typeof params === 'object') {
|
|
15
|
+
key += '?' + Object.entries(params).sort().map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
|
|
16
|
+
}
|
|
17
|
+
return key;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_debounce(cacheKey, delay, fn) {
|
|
21
|
+
if (this.__debounceTimers.has(cacheKey)) {
|
|
22
|
+
clearTimeout(this.__debounceTimers.get(cacheKey));
|
|
23
|
+
}
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
this.__debounceTimers.set(cacheKey, setTimeout(async () => {
|
|
26
|
+
this.__debounceTimers.delete(cacheKey);
|
|
27
|
+
try { resolve(await fn()); }
|
|
28
|
+
catch (err) { reject(err); }
|
|
29
|
+
}, delay));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async _parseJson(res, path) {
|
|
34
|
+
try {
|
|
35
|
+
return await res.json();
|
|
36
|
+
} catch {
|
|
37
|
+
const err = new Error(`Failed to parse JSON response from ${path} (HTTP ${res.status})`);
|
|
38
|
+
err.status = res.status;
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_buildUrl(path, params) {
|
|
44
|
+
let url = this.baseUrl + path;
|
|
45
|
+
if (params && typeof params === 'object') {
|
|
46
|
+
const qs = Object.entries(params)
|
|
47
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
48
|
+
.join('&');
|
|
49
|
+
if (qs) url += (url.includes('?') ? '&' : '?') + qs;
|
|
50
|
+
}
|
|
51
|
+
return url;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Abort any in-flight request for the same cache key
|
|
55
|
+
abort(cacheKey) {
|
|
56
|
+
const controller = this.__activeRequests.get(cacheKey);
|
|
57
|
+
if (controller) {
|
|
58
|
+
controller.abort();
|
|
59
|
+
this.__activeRequests.delete(cacheKey);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Unified request method — eliminates duplication across get/post/put/delete
|
|
64
|
+
async _request(method, path, opts = {}, body = undefined) {
|
|
65
|
+
const {
|
|
66
|
+
onSuccess, onError, cache = false, force = false, params = undefined,
|
|
67
|
+
debounce = 0, transform = undefined, headers = undefined, timeout = 30000,
|
|
68
|
+
} = opts;
|
|
69
|
+
const cacheKey = `${method}:${this._makeCacheKey(path, params)}`;
|
|
70
|
+
|
|
71
|
+
if (debounce > 0) {
|
|
72
|
+
return this._debounce(cacheKey, debounce, () =>
|
|
73
|
+
this._request(method, path, { ...opts, debounce: 0 }, body)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (cache && !force && this.__cache.has(cacheKey)) {
|
|
78
|
+
const cached = this.__cache.get(cacheKey);
|
|
79
|
+
if (onSuccess) setTimeout(() => EventBus.trigger(onSuccess, cached), 0);
|
|
80
|
+
return cached;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const url = this._buildUrl(path, params);
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
this.abort(cacheKey);
|
|
86
|
+
this.__activeRequests.set(cacheKey, controller);
|
|
87
|
+
|
|
88
|
+
const timeoutId = timeout > 0
|
|
89
|
+
? setTimeout(() => controller.abort(), timeout)
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
const fetchOpts = {
|
|
93
|
+
method,
|
|
94
|
+
headers: {
|
|
95
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
96
|
+
...this.headers,
|
|
97
|
+
...headers,
|
|
98
|
+
},
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
};
|
|
101
|
+
if (body !== undefined) fetchOpts.body = JSON.stringify(body);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(url, fetchOpts);
|
|
105
|
+
|
|
106
|
+
// Validate HTTP status before parsing
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const err = new Error(`HTTP ${res.status} ${res.statusText} on ${method} ${path}`);
|
|
109
|
+
err.status = res.status;
|
|
110
|
+
err.response = res;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let data = await this._parseJson(res, path);
|
|
115
|
+
if (transform && typeof transform === 'function') {
|
|
116
|
+
data = transform(data);
|
|
117
|
+
}
|
|
118
|
+
if (cache) this.__cache.set(cacheKey, data);
|
|
119
|
+
if (onSuccess) EventBus.trigger(onSuccess, data);
|
|
120
|
+
return data;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (onError) EventBus.trigger(onError, err);
|
|
123
|
+
throw err;
|
|
124
|
+
} finally {
|
|
125
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
126
|
+
this.__activeRequests.delete(cacheKey);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get(path, opts = {}) {
|
|
131
|
+
return this._request('GET', path, opts);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
post(path, body, opts = {}) {
|
|
135
|
+
return this._request('POST', path, opts, body);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
put(path, body, opts = {}) {
|
|
139
|
+
return this._request('PUT', path, opts, body);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
delete(path, opts = {}) {
|
|
143
|
+
return this._request('DELETE', path, opts);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// src/core/defineElement.js
|
|
2
|
+
import { css, unsafeCSS } from 'lit';
|
|
3
|
+
import { injectServices } from './service.js';
|
|
4
|
+
|
|
5
|
+
// Convert PascalCase or camelCase to kebab-case
|
|
6
|
+
function toKebabCase(str) {
|
|
7
|
+
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z])([A-Z][a-z])/g, '$1-$2').toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Custom element tags must contain a hyphen and only valid characters
|
|
11
|
+
const VALID_CE_TAG = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
|
|
12
|
+
|
|
13
|
+
export function defineElement({ styles, tag, props, params, inject, template } = {}) {
|
|
14
|
+
return function (target) {
|
|
15
|
+
if (styles) {
|
|
16
|
+
const arr = Array.isArray(styles) ? styles : [styles];
|
|
17
|
+
target.styles = arr.map(s => {
|
|
18
|
+
if (typeof s === 'string') return css`${unsafeCSS(s)}`;
|
|
19
|
+
return s;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (props)
|
|
24
|
+
target.properties = Object.assign({}, target.properties, props);
|
|
25
|
+
|
|
26
|
+
// Route params: register as Lit properties so they get default values
|
|
27
|
+
// and participate in the reactive update cycle.
|
|
28
|
+
if (params && typeof params === 'object') {
|
|
29
|
+
// Store the params schema so the routeParams setter can coerce types
|
|
30
|
+
target.__okalitParams = params;
|
|
31
|
+
target.properties = Object.assign({}, target.properties, params);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Service injection support
|
|
35
|
+
if (inject && Array.isArray(inject)) {
|
|
36
|
+
const orig = target.prototype.connectedCallback;
|
|
37
|
+
target.prototype.connectedCallback = function () {
|
|
38
|
+
injectServices(this, inject);
|
|
39
|
+
if (orig) orig.call(this);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Decoupled template: overrides the prototype's render method.
|
|
44
|
+
// When Lit calls this.render(), the context is the component instance.
|
|
45
|
+
if (template && typeof template === 'function') {
|
|
46
|
+
target.prototype.render = template;
|
|
47
|
+
}
|
|
48
|
+
// If no template is provided, Lit uses the render() defined in the class.
|
|
49
|
+
|
|
50
|
+
let finalTag = tag;
|
|
51
|
+
if (!finalTag)
|
|
52
|
+
finalTag = toKebabCase(target.name);
|
|
53
|
+
|
|
54
|
+
// Validate tag name to prevent malformed or dangerous custom element names
|
|
55
|
+
if (finalTag && !VALID_CE_TAG.test(finalTag)) {
|
|
56
|
+
console.error(`defineElement: invalid custom element tag "${finalTag}"`);
|
|
57
|
+
return target;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Skip if already defined (prevents DOMException during Vite HMR)
|
|
61
|
+
if (finalTag && !window.customElements.get(finalTag)) {
|
|
62
|
+
window.customElements.define(finalTag, target);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { EventBus } from './EventBus.js';
|
|
2
|
+
import { signal } from '@lit-labs/signals';
|
|
3
|
+
|
|
4
|
+
class OkalitI18nImpl {
|
|
5
|
+
constructor() {
|
|
6
|
+
this._translations = Object.create(null);
|
|
7
|
+
this._locale = signal('');
|
|
8
|
+
this._config = null;
|
|
9
|
+
this._ready = false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async init(config) {
|
|
13
|
+
this._config = {
|
|
14
|
+
default: config.default || 'es',
|
|
15
|
+
locales: config.locales || [],
|
|
16
|
+
path: config.path || '/i18n',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const persisted = localStorage.getItem('okalit:i18n:locale');
|
|
20
|
+
const initialLocale = (persisted && this._config.locales.includes(persisted))
|
|
21
|
+
? persisted
|
|
22
|
+
: this._config.default;
|
|
23
|
+
|
|
24
|
+
await this._loadLocale(initialLocale);
|
|
25
|
+
this._locale.set(initialLocale);
|
|
26
|
+
this._ready = true;
|
|
27
|
+
|
|
28
|
+
EventBus.emit('i18n:ready', { locale: initialLocale });
|
|
29
|
+
|
|
30
|
+
// Allow locale switching via EventBus
|
|
31
|
+
EventBus.listen('i18n:set-locale', ({ locale }) => this.setLocale(locale));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async _loadLocale(locale) {
|
|
35
|
+
if (this._translations[locale]) return;
|
|
36
|
+
|
|
37
|
+
const path = `${this._config.path}/${locale}.json`;
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(path);
|
|
40
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
41
|
+
this._translations[locale] = await res.json();
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error(`[OkalitI18n] Failed to load locale "${locale}" from ${path}:`, e);
|
|
44
|
+
this._translations[locale] = {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async setLocale(locale) {
|
|
49
|
+
if (!this._config?.locales.includes(locale)) {
|
|
50
|
+
console.warn(`[OkalitI18n] Locale "${locale}" is not configured. Available: ${this._config?.locales}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await this._loadLocale(locale);
|
|
55
|
+
this._locale.set(locale);
|
|
56
|
+
localStorage.setItem('okalit:i18n:locale', locale);
|
|
57
|
+
EventBus.emit('i18n:locale-changed', { locale });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get locale() {
|
|
61
|
+
return this._locale.get();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get locales() {
|
|
65
|
+
return this._config?.locales || [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
t(key, params) {
|
|
69
|
+
// Reading the signal value triggers SignalWatcher tracking
|
|
70
|
+
const locale = this._locale.get();
|
|
71
|
+
const dict = this._translations[locale] || {};
|
|
72
|
+
|
|
73
|
+
// Nested key support: "home.welcome.title" → dict.home.welcome.title
|
|
74
|
+
let value = key.split('.').reduce((obj, k) => obj?.[k], dict);
|
|
75
|
+
|
|
76
|
+
if (value === undefined) {
|
|
77
|
+
return key;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Interpolation: "Hola {name}" + { name: "Alex" } → "Hola Alex"
|
|
81
|
+
if (params && typeof value === 'string') {
|
|
82
|
+
value = value.replace(/\{(\w+)\}/g, (_, k) => (k in params) ? params[k] : `{${k}}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const OkalitI18n = new OkalitI18nImpl();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/core/idle.js
|
|
2
|
+
import { html } from 'lit';
|
|
3
|
+
|
|
4
|
+
export function idle({ template = () => html`<p>Loading...</p>`, dynamicLoader = [] } = {}) {
|
|
5
|
+
return (_, context) => {
|
|
6
|
+
context.addInitializer(function () {
|
|
7
|
+
const methodName = context.name;
|
|
8
|
+
const loaders = Array.isArray(dynamicLoader) ? dynamicLoader : [dynamicLoader];
|
|
9
|
+
this[`__${methodName}_loaded`] = false;
|
|
10
|
+
this[`__${methodName}_loading`] = false;
|
|
11
|
+
|
|
12
|
+
// Override the original method
|
|
13
|
+
const originalMethod = this[methodName];
|
|
14
|
+
this[methodName] = (...args) => {
|
|
15
|
+
if (this[`__${methodName}_loaded`]) {
|
|
16
|
+
return originalMethod.apply(this, args);
|
|
17
|
+
}
|
|
18
|
+
if (!this[`__${methodName}_loading`]) {
|
|
19
|
+
this[`__${methodName}_loading`] = true;
|
|
20
|
+
const load = () => {
|
|
21
|
+
Promise.all(loaders.map(fn => fn())).then(() => {
|
|
22
|
+
this[`__${methodName}_loaded`] = true;
|
|
23
|
+
this.requestUpdate && this.requestUpdate();
|
|
24
|
+
}).catch(err => {
|
|
25
|
+
this[`__${methodName}_loading`] = false;
|
|
26
|
+
console.error(`idle("${methodName}"): loader failed`, err);
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
if ('requestIdleCallback' in window) {
|
|
30
|
+
window.requestIdleCallback(load);
|
|
31
|
+
} else {
|
|
32
|
+
// Fallback when requestIdleCallback is not available
|
|
33
|
+
setTimeout(load, 200);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return template();
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Okalit } from './Okalit.js';
|
|
2
|
+
export { defineElement } from './defineElement.js';
|
|
3
|
+
export { AppMixin } from './AppMixin.js';
|
|
4
|
+
export { ModuleMixin } from './ModuleMixin.js';
|
|
5
|
+
export { OkalitService } from './OkalitService.js';
|
|
6
|
+
export { idle } from './idle.js';
|
|
7
|
+
export { lazy} from './lazy.js';
|
|
8
|
+
export { when } from './when.js';
|
|
9
|
+
export { service, getService, injectServices } from './service.js';
|
|
10
|
+
export { OkalitI18n } from './i18n.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/core/lazy.js
|
|
2
|
+
import { html } from 'lit';
|
|
3
|
+
|
|
4
|
+
export function lazy({ template = () => html`<p>Loading...</p>`, dynamicLoader = [] } = {}) {
|
|
5
|
+
return (_, context) => {
|
|
6
|
+
context.addInitializer(function () {
|
|
7
|
+
const methodName = context.name;
|
|
8
|
+
const loaders = Array.isArray(dynamicLoader) ? dynamicLoader : [dynamicLoader];
|
|
9
|
+
this[`__${methodName}_loaded`] = false;
|
|
10
|
+
this[`__${methodName}_loading`] = false;
|
|
11
|
+
|
|
12
|
+
// Override the original method
|
|
13
|
+
const originalMethod = this[methodName];
|
|
14
|
+
this[methodName] = (...args) => {
|
|
15
|
+
if (this[`__${methodName}_loaded`]) {
|
|
16
|
+
return originalMethod.apply(this, args);
|
|
17
|
+
}
|
|
18
|
+
if (!this[`__${methodName}_loading`]) {
|
|
19
|
+
this[`__${methodName}_loading`] = true;
|
|
20
|
+
Promise.all(loaders.map(fn => fn())).then(() => {
|
|
21
|
+
this[`__${methodName}_loaded`] = true;
|
|
22
|
+
this.requestUpdate && this.requestUpdate();
|
|
23
|
+
}).catch(err => {
|
|
24
|
+
this[`__${methodName}_loading`] = false;
|
|
25
|
+
console.error(`lazy("${methodName}"): loader failed`, err);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return template();
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
}
|