@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.
Files changed (49) hide show
  1. package/README.md +13 -9
  2. package/lib/cli.js +38 -30
  3. package/package.json +1 -1
  4. package/templates/app/@okalit/Okalit.js +163 -89
  5. package/templates/app/@okalit/channel.js +177 -0
  6. package/templates/app/@okalit/define-element.js +30 -0
  7. package/templates/app/@okalit/i18n.js +88 -53
  8. package/templates/app/@okalit/index.js +8 -10
  9. package/templates/app/@okalit/mixins.js +106 -0
  10. package/templates/app/@okalit/performance.js +211 -0
  11. package/templates/app/@okalit/router-outlet.js +66 -0
  12. package/templates/app/@okalit/router.js +240 -0
  13. package/templates/app/@okalit/service.js +318 -23
  14. package/templates/app/index.html +0 -1
  15. package/templates/app/package.json +2 -3
  16. package/templates/app/public/i18n/en.json +47 -1
  17. package/templates/app/public/i18n/es.json +47 -1
  18. package/templates/app/src/{app.routes.ts → app.routes.js} +1 -1
  19. package/templates/app/src/channels/example.channel.js +15 -0
  20. package/templates/app/src/components/lazy-widget.js +13 -0
  21. package/templates/app/src/guards/auth.guard.js +17 -0
  22. package/templates/app/src/layouts/app-layout.js +75 -0
  23. package/templates/app/src/main-app.js +19 -9
  24. package/templates/app/src/modules/example/example.module.js +8 -3
  25. package/templates/app/src/modules/example/example.routes.js +27 -4
  26. package/templates/app/src/modules/example/pages/detail/example-detail.js +21 -0
  27. package/templates/app/src/modules/example/pages/home/example-home.js +39 -0
  28. package/templates/app/src/modules/example/pages/lifecycle/example-lifecycle.js +74 -0
  29. package/templates/app/src/modules/example/pages/performance/example-performance.js +59 -0
  30. package/templates/app/src/modules/example/pages/services/example-services.js +80 -0
  31. package/templates/app/src/services/rickandmorty.service.js +17 -0
  32. package/templates/app/src/services/user.service.js +33 -0
  33. package/templates/app/src/styles/global.scss +250 -0
  34. package/templates/app/src/styles/index.css +11 -2
  35. package/templates/app/vite.config.js +2 -0
  36. package/templates/app/@okalit/AppMixin.js +0 -29
  37. package/templates/app/@okalit/EventBus.js +0 -152
  38. package/templates/app/@okalit/ModuleMixin.js +0 -7
  39. package/templates/app/@okalit/OkalitService.js +0 -145
  40. package/templates/app/@okalit/defineElement.js +0 -65
  41. package/templates/app/@okalit/idle.js +0 -40
  42. package/templates/app/@okalit/lazy.js +0 -32
  43. package/templates/app/@okalit/okalit-router.js +0 -309
  44. package/templates/app/@okalit/trigger.js +0 -14
  45. package/templates/app/@okalit/viewport.js +0 -69
  46. package/templates/app/@okalit/when.js +0 -40
  47. package/templates/app/public/lit.svg +0 -1
  48. package/templates/app/src/modules/example/pages/example.page.js +0 -43
  49. package/templates/app/src/modules/example/pages/example.page.scss +0 -76
@@ -0,0 +1,30 @@
1
+ export function defineElement({ tag, styles = [], props = [], params = [] }) {
2
+ return function (cls, context) {
3
+ // Inject styles, props and params as static properties on the class
4
+ cls.styles = styles;
5
+ cls.props = props;
6
+ cls.params = params;
7
+
8
+ // Build a map of prop name → type config for attribute coercion
9
+ const propMap = {};
10
+ for (const propDef of props) {
11
+ const [name, config] = Object.entries(propDef)[0];
12
+ propMap[name] = config;
13
+ }
14
+ cls._propMap = propMap;
15
+
16
+ // Tell the browser which attributes to observe (kebab-case)
17
+ cls.observedAttributes = Object.keys(propMap).map(toKebabCase);
18
+
19
+ // Register the custom element after the class is fully defined
20
+ context.addInitializer(function () {
21
+ if (!customElements.get(tag)) {
22
+ customElements.define(tag, cls);
23
+ }
24
+ });
25
+ };
26
+ }
27
+
28
+ function toKebabCase(str) {
29
+ return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
30
+ }
@@ -1,89 +1,124 @@
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;
1
+ import { signal, computed } from 'uhtml';
2
+
3
+ let instance = null;
4
+
5
+ class I18n {
6
+ constructor(config) {
7
+ if (instance) return instance;
8
+ instance = this;
9
+
10
+ this._translations = {};
11
+ this._defaultLocale = config.default || 'en';
12
+ this._locales = config.locales || [this._defaultLocale];
13
+ this.locale = signal(this._detectLocale());
14
+ this._version = signal(0);
9
15
  this._ready = false;
16
+ this._readyPromise = this._loadLocale(this.locale.value);
10
17
  }
11
18
 
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;
19
+ static getInstance() {
20
+ return instance;
21
+ }
23
22
 
24
- await this._loadLocale(initialLocale);
25
- this._locale.set(initialLocale);
26
- this._ready = true;
23
+ _detectLocale() {
24
+ // Check localStorage first, then browser language
25
+ const stored = localStorage.getItem('okalit:locale');
26
+ if (stored && this._locales.includes(stored)) return stored;
27
27
 
28
- EventBus.emit('i18n:ready', { locale: initialLocale });
28
+ const browserLang = navigator.language?.split('-')[0];
29
+ if (browserLang && this._locales.includes(browserLang)) return browserLang;
29
30
 
30
- // Allow locale switching via EventBus
31
- EventBus.listen('i18n:set-locale', ({ locale }) => this.setLocale(locale));
31
+ return this._defaultLocale;
32
32
  }
33
33
 
34
34
  async _loadLocale(locale) {
35
- if (this._translations[locale]) return;
35
+ if (this._translations[locale]) {
36
+ this._ready = true;
37
+ return;
38
+ }
36
39
 
37
- const path = `${this._config.path}/${locale}.json`;
38
40
  try {
39
- const res = await fetch(path);
40
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
41
+ const res = await fetch(`/i18n/${locale}.json`);
41
42
  this._translations[locale] = await res.json();
43
+ this._ready = true;
44
+ this._version.value++;
42
45
  } catch (e) {
43
- console.error(`[OkalitI18n] Failed to load locale "${locale}" from ${path}:`, e);
46
+ console.warn(`[i18n] Failed to load locale: ${locale}`, e);
44
47
  this._translations[locale] = {};
45
48
  }
46
49
  }
47
50
 
48
51
  async setLocale(locale) {
49
- if (!this._config?.locales.includes(locale)) {
50
- console.warn(`[OkalitI18n] Locale "${locale}" is not configured. Available: ${this._config?.locales}`);
52
+ if (!this._locales.includes(locale)) {
53
+ console.warn(`[i18n] Unknown locale: ${locale}`);
51
54
  return;
52
55
  }
53
56
 
54
57
  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 || [];
58
+ this.locale.value = locale;
59
+ localStorage.setItem('okalit:locale', locale);
66
60
  }
67
61
 
68
- t(key, params) {
69
- // Reading the signal value triggers SignalWatcher tracking
70
- const locale = this._locale.get();
71
- const dict = this._translations[locale] || {};
62
+ /**
63
+ * Translate a key. Supports nested keys with dots: t('SECTION.KEY')
64
+ * Supports interpolation: t('HELLO', { name: 'World' }) → "Hello, World"
65
+ */
66
+ translate(key, params) {
67
+ const lang = this.locale.value;
68
+ const dict = this._translations[lang] || {};
72
69
 
73
- // Nested key support: "home.welcome.title" → dict.home.welcome.title
70
+ // Support nested keys: 'SECTION.KEY'
74
71
  let value = key.split('.').reduce((obj, k) => obj?.[k], dict);
75
72
 
76
73
  if (value === undefined) {
77
- return key;
74
+ // Fallback to default locale
75
+ const fallback = this._translations[this._defaultLocale] || {};
76
+ value = key.split('.').reduce((obj, k) => obj?.[k], fallback);
78
77
  }
79
78
 
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}}`);
79
+ if (value === undefined) return key;
80
+
81
+ // Interpolation: replace {{ name }} with params.name
82
+ if (params) {
83
+ value = value.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => params[k] ?? '');
83
84
  }
84
85
 
85
86
  return value;
86
87
  }
88
+
89
+ get ready() {
90
+ return this._readyPromise;
91
+ }
92
+
93
+ destroy() {
94
+ instance = null;
95
+ }
87
96
  }
88
97
 
89
- export const OkalitI18n = new OkalitI18nImpl();
98
+ /**
99
+ * Initialize i18n. Called from AppMixin.
100
+ */
101
+ export function createI18n(config) {
102
+ return new I18n(config);
103
+ }
104
+
105
+ /**
106
+ * Translate a key. Reactive — re-renders when locale changes.
107
+ * Usage: t('WELCOME') or t('HELLO', { name: 'World' })
108
+ */
109
+ export function t(key, params) {
110
+ const i18n = I18n.getInstance();
111
+ if (!i18n) return key;
112
+ // Reading locale.value + _version makes this reactive inside uhtml effects
113
+ i18n.locale.value;
114
+ i18n._version.value;
115
+ i18n.locale.value;
116
+ return i18n.translate(key, params);
117
+ }
118
+
119
+ /**
120
+ * Get the i18n instance for setLocale, etc.
121
+ */
122
+ export function getI18n() {
123
+ return I18n.getInstance();
124
+ }
@@ -1,10 +1,8 @@
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';
1
+ export { Okalit, html, signal, computed, effect, batch } from './Okalit.js';
2
+ export { defineElement } from './define-element.js';
3
+ export { defineChannel } from './channel.js';
4
+ export { Router, navigate } from './router.js';
5
+ export { AppMixin, ModuleMixin, PageMixin } from './mixins.js';
6
+ export { t, getI18n } from './i18n.js';
7
+ export { OkalitService, OkalitGraphqlService, RequestControl, service, inject } from './service.js';
8
+ export { OIdle, OWhen, OViewport } from './performance.js';
@@ -0,0 +1,106 @@
1
+ import { html} from 'uhtml';
2
+ import { Router, navigate } from './router.js';
3
+ import { createI18n } from './i18n.js';
4
+ import './router-outlet.js';
5
+
6
+ /**
7
+ * AppMixin — for the root application component.
8
+ * Initializes the router with the provided routes.
9
+ *
10
+ * Usage:
11
+ * class MainApp extends AppMixin(Okalit) {
12
+ * static routes = [...];
13
+ * }
14
+ */
15
+ export const AppMixin = (Base) => class extends Base {
16
+ static routes = [];
17
+
18
+ constructor() {
19
+ super();
20
+ this._router = new Router(this.constructor.routes);
21
+
22
+ const i18nConfig = this.constructor.i18n;
23
+ if (i18nConfig) {
24
+ this._i18n = createI18n(i18nConfig);
25
+ }
26
+ }
27
+
28
+ async switchLocale(locale) {
29
+ await this._i18n?.setLocale(locale);
30
+ }
31
+
32
+ get router() {
33
+ return this._router;
34
+ }
35
+
36
+ navigate(path, options) {
37
+ this._router.navigate(path, options);
38
+ }
39
+
40
+ disconnectedCallback() {
41
+ super.disconnectedCallback?.();
42
+ this._router.destroy();
43
+ }
44
+
45
+ render() {
46
+ return html`
47
+ <okalit-router></okalit-router>
48
+ `;
49
+ }
50
+ };
51
+
52
+ /**
53
+ * ModuleMixin — for feature modules that group pages.
54
+ * Provides a nested <okalit-router> for child routes.
55
+ *
56
+ * Usage:
57
+ * class ExampleModule extends ModuleMixin(Okalit) {
58
+ * render() {
59
+ * return html`<okalit-router></okalit-router>`;
60
+ * }
61
+ * }
62
+ */
63
+ export const ModuleMixin = (Base) => class extends Base {
64
+ get router() {
65
+ return Router.getInstance();
66
+ }
67
+
68
+ navigate(path, options) {
69
+ Router.getInstance()?.navigate(path, options);
70
+ }
71
+
72
+ render() {
73
+ return html`
74
+ <okalit-router></okalit-router>
75
+ `;
76
+ }
77
+ };
78
+
79
+ /**
80
+ * PageMixin — for individual pages within a module.
81
+ * Provides router access and navigation helpers.
82
+ *
83
+ * Usage:
84
+ * class HomePage extends PageMixin(Okalit) {
85
+ * render() { ... }
86
+ * }
87
+ */
88
+ export const PageMixin = (Base) => class extends Base {
89
+ get router() {
90
+ return Router.getInstance();
91
+ }
92
+
93
+ get routeParams() {
94
+ return Router.getInstance()?.params.value || {};
95
+ }
96
+
97
+ get queryParams() {
98
+ return Router.getInstance()?.query.value || {};
99
+ }
100
+
101
+ navigate(path, options) {
102
+ Router.getInstance()?.navigate(path, options);
103
+ }
104
+ };
105
+
106
+ export { navigate };
@@ -0,0 +1,211 @@
1
+ // ── Shared styles ──────────────────────────────────────────────
2
+
3
+ const SHARED_STYLES = `
4
+ :host { display: contents; }
5
+ ::slotted([slot="fallback"]) { display: none; }
6
+ :host(:not([loaded])) ::slotted(:not([slot])) { display: none; }
7
+ :host(:not([loaded])) ::slotted([slot="fallback"]) { display: contents; }
8
+ `;
9
+
10
+ // ── Shared loader logic ────────────────────────────────────────
11
+
12
+ /**
13
+ * Normalise .loader — accepts a single function or an array.
14
+ * Resolves all imports with Promise.all and flips #isLoaded.
15
+ * On error, dispatches 'o-error' on the host element.
16
+ */
17
+ async function executeLoader(host) {
18
+ if (host._done || host._loading) return;
19
+ host._loading = true;
20
+
21
+ const loaders = Array.isArray(host.loader) ? host.loader : [host.loader];
22
+
23
+ try {
24
+ await Promise.all(loaders.map((fn) => fn()));
25
+ host._done = true;
26
+ host.setAttribute('loaded', '');
27
+ } catch (err) {
28
+ host.dispatchEvent(
29
+ new CustomEvent('o-error', {
30
+ detail: err,
31
+ bubbles: true,
32
+ composed: true,
33
+ })
34
+ );
35
+ } finally {
36
+ host._loading = false;
37
+ }
38
+ }
39
+
40
+ function applySharedSetup(shadowRoot) {
41
+ const style = document.createElement('style');
42
+ style.textContent = SHARED_STYLES;
43
+ shadowRoot.append(
44
+ style,
45
+ document.createElement('slot'), // default slot
46
+ Object.assign(document.createElement('slot'), { name: 'fallback' }),
47
+ );
48
+ }
49
+
50
+ // ── <o-idle> ───────────────────────────────────────────────────
51
+
52
+ export class OIdle extends HTMLElement {
53
+ _done = false;
54
+ _loading = false;
55
+ #loader = null;
56
+ #idleId = null;
57
+ #timeoutId = null;
58
+ #connected = false;
59
+
60
+ get loader() { return this.#loader; }
61
+ set loader(fn) {
62
+ if (this._done || this._loading) return;
63
+ this.#loader = fn;
64
+ if (fn && this.#connected) this.#schedule();
65
+ }
66
+
67
+ constructor() {
68
+ super();
69
+ this.attachShadow({ mode: 'open' });
70
+ applySharedSetup(this.shadowRoot);
71
+ }
72
+
73
+ connectedCallback() {
74
+ this.#connected = true;
75
+ if (this.#loader) this.#schedule();
76
+ }
77
+
78
+ #schedule() {
79
+ if (this._done || this._loading || this.#idleId != null || this.#timeoutId != null) return;
80
+ if (typeof requestIdleCallback === 'function') {
81
+ this.#idleId = requestIdleCallback(() => executeLoader(this));
82
+ } else {
83
+ this.#timeoutId = setTimeout(() => executeLoader(this), 200);
84
+ }
85
+ }
86
+
87
+ disconnectedCallback() {
88
+ this.#connected = false;
89
+ if (this.#idleId != null) {
90
+ cancelIdleCallback(this.#idleId);
91
+ this.#idleId = null;
92
+ }
93
+ if (this.#timeoutId != null) {
94
+ clearTimeout(this.#timeoutId);
95
+ this.#timeoutId = null;
96
+ }
97
+ }
98
+ }
99
+
100
+ // ── <o-when> ───────────────────────────────────────────────────
101
+
102
+ export class OWhen extends HTMLElement {
103
+ _done = false;
104
+ _loading = false;
105
+ #loader = null;
106
+ #condition = false;
107
+ #triggered = false;
108
+
109
+ get loader() { return this.#loader; }
110
+ set loader(fn) {
111
+ if (this._done || this._loading) return;
112
+ this.#loader = fn;
113
+ this.#tryLoad();
114
+ }
115
+
116
+ set condition(val) {
117
+ this.#condition = !!val;
118
+ this.#tryLoad();
119
+ }
120
+
121
+ #tryLoad() {
122
+ if (this.#triggered || this._done || this._loading) return;
123
+ if (this.#condition && this.#loader) {
124
+ this.#triggered = true;
125
+ executeLoader(this);
126
+ }
127
+ }
128
+
129
+ constructor() {
130
+ super();
131
+ this.attachShadow({ mode: 'open' });
132
+ applySharedSetup(this.shadowRoot);
133
+ }
134
+ }
135
+
136
+ // ── <o-viewport> ───────────────────────────────────────────────
137
+
138
+ export class OViewport extends HTMLElement {
139
+ _done = false;
140
+ _loading = false;
141
+ #loader = null;
142
+ #observer = null;
143
+ #connected = false;
144
+ #sentinel = null;
145
+
146
+ get loader() { return this.#loader; }
147
+ set loader(fn) {
148
+ if (this._done || this._loading) return;
149
+ this.#loader = fn;
150
+ if (fn && this.#connected) this.#observe();
151
+ }
152
+
153
+ constructor() {
154
+ super();
155
+ this.attachShadow({ mode: 'open' });
156
+
157
+ // Sentinel: a 1px element the IntersectionObserver can track,
158
+ // because :host { display: contents } has no box model.
159
+ this.#sentinel = document.createElement('span');
160
+ this.#sentinel.style.cssText = 'display:block;width:1px;height:1px;pointer-events:none;';
161
+
162
+ const style = document.createElement('style');
163
+ style.textContent = `
164
+ :host { display: contents; }
165
+ ::slotted([slot="fallback"]) { display: none; }
166
+ :host(:not([loaded])) ::slotted(:not([slot])) { display: none; }
167
+ :host(:not([loaded])) ::slotted([slot="fallback"]) { display: contents; }
168
+ `;
169
+ this.shadowRoot.append(
170
+ style,
171
+ this.#sentinel,
172
+ document.createElement('slot'),
173
+ Object.assign(document.createElement('slot'), { name: 'fallback' }),
174
+ );
175
+ }
176
+
177
+ connectedCallback() {
178
+ this.#connected = true;
179
+ if (this.#loader) this.#observe();
180
+ }
181
+
182
+ #observe() {
183
+ if (this._done || this._loading || this.#observer) return;
184
+ this.#observer = new IntersectionObserver(
185
+ (entries) => {
186
+ for (const entry of entries) {
187
+ if (entry.isIntersecting) {
188
+ this.#observer.disconnect();
189
+ this.#observer = null;
190
+ executeLoader(this);
191
+ return;
192
+ }
193
+ }
194
+ },
195
+ { rootMargin: '200px' }
196
+ );
197
+ this.#observer.observe(this.#sentinel);
198
+ }
199
+
200
+ disconnectedCallback() {
201
+ this.#connected = false;
202
+ this.#observer?.disconnect();
203
+ this.#observer = null;
204
+ }
205
+ }
206
+
207
+ // ── Register elements ──────────────────────────────────────────
208
+
209
+ if (!customElements.get('o-idle')) customElements.define('o-idle', OIdle);
210
+ if (!customElements.get('o-when')) customElements.define('o-when', OWhen);
211
+ if (!customElements.get('o-viewport')) customElements.define('o-viewport', OViewport);
@@ -0,0 +1,66 @@
1
+ import { Router } from './router.js';
2
+
3
+ export class OkalitRouter extends HTMLElement {
4
+ constructor() {
5
+ super();
6
+ this.attachShadow({ mode: 'open' });
7
+ const style = document.createElement('style');
8
+ style.textContent = ':host { display: contents; }';
9
+ this.shadowRoot.appendChild(style);
10
+ this._currentComponent = null;
11
+ this._depth = 0;
12
+ this._renderVersion = 0;
13
+ }
14
+
15
+ connectedCallback() {
16
+ this._depth = 0;
17
+ let root = this.getRootNode();
18
+ while (root && root.host) {
19
+ if (root.host.tagName === 'OKALIT-ROUTER') {
20
+ this._depth++;
21
+ }
22
+ root = root.host.getRootNode();
23
+ }
24
+
25
+ const router = Router.getInstance();
26
+ if (router) {
27
+ router.registerOutlet(this);
28
+ }
29
+ }
30
+
31
+ disconnectedCallback() {
32
+ const router = Router.getInstance();
33
+ if (router) {
34
+ router.unregisterOutlet(this);
35
+ }
36
+ }
37
+
38
+ async _renderRoute(match) {
39
+ const route = match.chain[this._depth];
40
+ if (!route) return;
41
+
42
+ // Skip if same component already rendered
43
+ if (this._currentComponent === route.component) return;
44
+
45
+ // Version guard: if another _renderRoute starts while we await,
46
+ // the earlier one becomes stale and should bail out.
47
+ const version = ++this._renderVersion;
48
+
49
+ if (route.import) {
50
+ await route.import();
51
+ }
52
+
53
+ // Bail out if a newer render was triggered while awaiting
54
+ if (version !== this._renderVersion) return;
55
+
56
+ this._currentComponent = route.component;
57
+
58
+ this.shadowRoot.innerHTML = '';
59
+ const el = document.createElement(route.component);
60
+ this.shadowRoot.appendChild(el);
61
+ }
62
+ }
63
+
64
+ if (!customElements.get('okalit-router')) {
65
+ customElements.define('okalit-router', OkalitRouter);
66
+ }