@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
package/README.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  CLI para crear apps Okalit y generar recursos dentro de un proyecto existente.
4
4
 
5
- ## Uso
5
+ # Okalit CLI
6
+
7
+ CLI to create Okalit apps and generate resources inside an existing project.
8
+
9
+ ## Usage
6
10
 
7
11
  ```bash
8
12
  npx okalit-cli new app-name
@@ -13,16 +17,16 @@ npx okalit-cli -g --guard ./src/guards/auth
13
17
  npx okalit-cli -g --interceptor ./src/interceptors/auth
14
18
  ```
15
19
 
16
- ## Qué genera
20
+ ## What it generates
17
21
 
18
- - `new <app-name>`: crea una app Vite basada en el template de Okalit.
19
- - `-g -c`: crea carpeta del componente con `js` y `scss` o `css` según el proyecto.
20
- - `-g -s`: crea un archivo `*.service.js` en la ruta indicada.
21
- - `-g -m`: crea carpeta del módulo con `*.module.js`, `*.routes.js` y página inicial.
22
- - `-g --guard`: crea un archivo `*.guard.js`.
23
- - `-g --interceptor`: crea un archivo `*.interceptor.js`.
22
+ - `new <app-name>`: creates a Vite app based on the Okalit template.
23
+ - `-g -c`: creates a component folder with `js` and `scss` or `css` depending on the project.
24
+ - `-g -s`: creates a `*.service.js` file at the specified path.
25
+ - `-g -m`: creates a module folder with `*.module.js`, `*.routes.js`, and a starter page.
26
+ - `-g --guard`: creates a `*.guard.js` file.
27
+ - `-g --interceptor`: creates a `*.interceptor.js` file.
24
28
 
25
- ## Desarrollo local
29
+ ## Local development
26
30
 
27
31
  ```bash
28
32
  cd okalit-cli
package/lib/cli.js CHANGED
@@ -14,7 +14,7 @@ const HELP_CONTENT = `
14
14
  ██ ██ ██▀██▄ ▄██▀▀▀██ ██ ██ ██
15
15
  ██▄▄██ ██ ▀█▄ ██▄▄▄███ ██▄▄▄ ▄▄▄██▄▄▄ ██▄▄▄
16
16
  ▀▀▀▀ ▀▀ ▀▀▀ ▀▀▀▀ ▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀
17
- By LIAPF Okalit CLI - v0.0.1
17
+ By LIAPF Okalit CLI - v0.2.0
18
18
 
19
19
  Usage:
20
20
  okalit new <app-name>
@@ -26,9 +26,9 @@ Commands:
26
26
  generate, -g
27
27
  -c, --component <path> Create a component
28
28
  -s, --service <path> Create a service
29
+ --gqservice <path> Create a GraphQL service
29
30
  -m, --module <path> Create a module
30
31
  --guard <path> Create a guard
31
- --interceptor <path> Create an interceptor
32
32
 
33
33
  Examples:
34
34
  okalit new my-app
@@ -114,6 +114,7 @@ async function handleGenerate(argv, cwd) {
114
114
  const targets = [
115
115
  { kind: 'component', flags: ['-c', '--component'] },
116
116
  { kind: 'service', flags: ['-s', '--service'] },
117
+ { kind: 'gqservice', flags: ['--gqservice'] },
117
118
  { kind: 'module', flags: ['-m', '--module'] },
118
119
  { kind: 'guard', flags: ['--guard'] },
119
120
  { kind: 'interceptor', flags: ['--interceptor'] },
@@ -132,15 +133,15 @@ async function handleGenerate(argv, cwd) {
132
133
  case 'service':
133
134
  generatePlainFile(projectRoot, value, 'service', buildServiceTemplate(value, coreAlias));
134
135
  break;
136
+ case 'gqservice':
137
+ generatePlainFile(projectRoot, value, 'service', buildGQServiceTemplate(value, coreAlias));
138
+ break;
135
139
  case 'module':
136
140
  generateModule(projectRoot, value, styleExt, coreAlias, globalStyleSpecifier);
137
141
  break;
138
142
  case 'guard':
139
143
  generatePlainFile(projectRoot, value, 'guard', buildGuardTemplate(value));
140
144
  break;
141
- case 'interceptor':
142
- generatePlainFile(projectRoot, value, 'interceptor', buildInterceptorTemplate(value));
143
- break;
144
145
  default:
145
146
  break;
146
147
  }
@@ -211,8 +212,7 @@ function buildComponentTemplate(tagName, className, styleExt, coreAlias, globalS
211
212
  : '';
212
213
  const stylesArray = globalStyleSpecifier ? '[styles, global]' : '[styles]';
213
214
 
214
- return `import { html } from "lit";
215
- import { Okalit, defineElement } from "${coreAlias}";
215
+ return `import { Okalit, defineElement, html } from "${coreAlias}";
216
216
 
217
217
  import styles from "./${tagName}.${styleExt}?inline";${globalStyleImport}
218
218
 
@@ -257,8 +257,8 @@ function buildPageTemplate(pageName, className, styleExt, coreAlias, globalStyle
257
257
  : '';
258
258
  const stylesArray = globalStyleSpecifier ? '[styles, global]' : '[styles]';
259
259
 
260
- return `import { html } from "lit";
261
- import { Okalit, defineElement } from "${coreAlias}";
260
+ return `
261
+ import { Okalit, defineElement, html, PageMixin, t } from "${coreAlias}";
262
262
 
263
263
  import styles from "./${pageName}.page.${styleExt}?inline";
264
264
  ${globalStyleImport}
@@ -267,11 +267,11 @@ ${globalStyleImport}
267
267
  tag: "${pageName}-page",
268
268
  styles: ${stylesArray}
269
269
  })
270
- export class ${className}Page extends Okalit {
270
+ export class ${className}Page extends PageMixin(Okalit) {
271
271
  render() {
272
272
  return html\`
273
273
  <main>
274
- <h1>${'${this.t("WELCOME")}'}</h1>
274
+ <h1>${'${t("WELCOME")}'}</h1>
275
275
  </main>
276
276
  \`;
277
277
  }
@@ -279,48 +279,56 @@ export class ${className}Page extends Okalit {
279
279
  `;
280
280
  }
281
281
 
282
- function buildServiceTemplate(rawTarget, coreAlias) {
282
+ function buildGQServiceTemplate(_, coreAlias) {
283
283
  return (baseName) => {
284
284
  const className = `${toPascalCase(baseName)}Service`;
285
285
  const serviceName = `${toCamelCase(baseName)}Service`;
286
- const resourcePath = `/${baseName}`;
287
286
 
288
- return `import { OkalitService, service } from "${coreAlias}";
287
+ return `import { OkalitGraphqlService, service } from "${coreAlias}";
289
288
 
290
289
  @service("${serviceName}")
291
- export class ${className} extends OkalitService {
290
+ export class ${className} extends OkalitGraphqlService {
292
291
  constructor() {
293
292
  super();
294
- this.baseUrl = "";
295
- this.version = "1";
296
- this.path = "${resourcePath}";
297
- this.headers = {};
293
+ this.configure({
294
+ endpoint: '',
295
+ cache: true,
296
+ cacheTTL: 120_000,
297
+ });
298
298
  }
299
299
  }
300
300
  `;
301
301
  };
302
- }
302
+ }
303
303
 
304
- function buildGuardTemplate(rawTarget) {
304
+ function buildServiceTemplate(_, coreAlias) {
305
305
  return (baseName) => {
306
- const functionName = `${toCamelCase(baseName)}Guard`;
306
+ const className = `${toPascalCase(baseName)}Service`;
307
+ const serviceName = `${toCamelCase(baseName)}Service`;
307
308
 
308
- return `export async function ${functionName}(path, args) {
309
- void path;
310
- void args;
309
+ return `import { OkalitService, service } from "${coreAlias}";
311
310
 
312
- return { allow: true };
311
+ @service("${serviceName}")
312
+ export class ${className} extends OkalitService {
313
+ constructor() {
314
+ super();
315
+ this.configure({
316
+ baseUrl: '',
317
+ cache: true,
318
+ cacheTTL: 60_000,
319
+ });
320
+ }
313
321
  }
314
322
  `;
315
323
  };
316
324
  }
317
325
 
318
- function buildInterceptorTemplate(rawTarget) {
326
+ function buildGuardTemplate(rawTarget) {
319
327
  return (baseName) => {
320
- const functionName = `${toCamelCase(baseName)}Interceptor`;
328
+ const functionName = `${toCamelCase(baseName)}Guard`;
321
329
 
322
- return `export async function ${functionName}(context) {
323
- return context;
330
+ return `export async function ${functionName}() {
331
+ return true;
324
332
  }
325
333
  `;
326
334
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okalit/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI to create and generate resources for Okalit projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,129 +1,203 @@
1
+ import { render, html, signal, computed, effect, batch } from 'uhtml';
2
+ import { initChannels } from './channel.js';
1
3
 
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';
4
+ export { html, signal, computed, effect, batch };
5
+
6
+ export class Okalit extends HTMLElement {
7
+ // The @defineElement decorator will populate these static properties
8
+ static styles = [];
9
+ static props = [];
10
+ static params = [];
6
11
 
7
- export class Okalit extends SignalWatcher(LitElement) {
8
12
  constructor() {
9
13
  super();
14
+ this.attachShadow({ mode: 'open' });
15
+ this._initialized = false;
16
+ this._dispose = [];
17
+ this._signals = {};
10
18
 
11
- this._okalit_channel_unsubs = [];
12
- this._okalit_listen_unsubs = [];
19
+ // Create reactive signals for each declared prop
20
+ this._initProps();
13
21
 
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
- }
22
+ // Create reactive signals from URL query params
23
+ this._initParams();
24
+
25
+ // Initialize channels declared in static channels
26
+ this._dispose.push(...initChannels(this));
27
+ }
28
+
29
+ _initProps() {
30
+ const props = this.constructor.props;
31
+ if (!props.length) return;
32
+
33
+ for (const propDef of props) {
34
+ const [name, config] = Object.entries(propDef)[0];
35
+ this._signals[name] = signal(config.value);
36
+
37
+ // Public getter/setter so that:
38
+ // - this.name (get) returns the signal (use .value in templates)
39
+ // - element.name = 'x' (set) updates the signal (uhtml .prop syntax)
40
+ Object.defineProperty(this, name, {
41
+ get: () => this._signals[name],
42
+ set: (val) => {
43
+ // If someone passes a raw value (uhtml .prop, or JS), update the signal
44
+ if (val && typeof val === 'object' && 'value' in val && val === this._signals[name]) return;
45
+ this._signals[name].value = val;
46
+ },
47
+ configurable: true,
48
+ enumerable: true,
49
+ });
19
50
  }
20
51
  }
21
52
 
53
+ attributeChangedCallback(attr, oldVal, newVal) {
54
+ // Convert kebab-case attribute to camelCase prop name
55
+ const propName = attr.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
56
+ const config = this.constructor._propMap?.[propName];
57
+ if (!config) return;
58
+
59
+ // Guard: signals may not exist yet during construction
60
+ const sig = this._signals?.[propName];
61
+ if (!sig) return;
62
+
63
+ sig.value = coerceValue(newVal, config.type);
64
+ }
65
+
22
66
  connectedCallback() {
23
- super.connectedCallback();
67
+ this._applyStyles();
24
68
 
25
- if (typeof this.okalitConnections === 'function') {
26
- this.okalitConnections();
69
+ // Sync any attributes that were set before signals were ready
70
+ this._syncAttributes();
71
+
72
+ if (!this._initialized) {
73
+ this._initialized = true;
74
+ this.onInit();
27
75
  }
76
+
77
+ // Watch all prop signals for onChange hook
78
+ this._watchProps();
79
+
80
+ // Use explicit effect so render() always receives a Hole, not a function.
81
+ // The effect tracks which signals are read during render and re-runs automatically.
82
+ const dispose = effect(() => {
83
+ this.onBeforeRender();
84
+ render(this.shadowRoot, this.render());
85
+ this.onAfterRender();
86
+ });
87
+ this._dispose.push(dispose);
28
88
  }
29
89
 
30
- disconnectedCallback() {
31
- super.disconnectedCallback();
90
+ _syncAttributes() {
91
+ const propMap = this.constructor._propMap;
92
+ if (!propMap) return;
32
93
 
33
- if (this._okalit_channel_unsubs.length > 0) {
34
- this._okalit_channel_unsubs.forEach(unsub => unsub());
35
- this._okalit_channel_unsubs = [];
94
+ for (const [propName, config] of Object.entries(propMap)) {
95
+ const attr = propName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
96
+ if (this.hasAttribute(attr)) {
97
+ this._signals[propName].value = coerceValue(this.getAttribute(attr), config.type);
98
+ }
36
99
  }
100
+ }
37
101
 
38
- if (this._okalit_listen_unsubs.length > 0) {
39
- this._okalit_listen_unsubs.forEach(unsub => unsub());
40
- this._okalit_listen_unsubs = [];
41
- }
102
+ _watchProps() {
103
+ const props = this.constructor.props;
104
+ if (!props.length) return;
105
+
106
+ for (const propDef of props) {
107
+ const [name] = Object.entries(propDef)[0];
108
+ let previous = this._signals[name].value;
109
+
110
+ const dispose = effect(() => {
111
+ const current = this._signals[name].value;
112
+ if (previous !== current) {
113
+ const old = previous;
114
+ previous = current;
115
+ this.onChange({ [name]: { previous: old, current } });
116
+ }
117
+ });
42
118
 
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 = [];
119
+ this._dispose.push(dispose);
47
120
  }
48
121
  }
49
122
 
50
- output(eventName, detail) {
51
- this.dispatchEvent(new CustomEvent(eventName, {
52
- detail,
53
- bubbles: true,
54
- composed: true
55
- }));
123
+ disconnectedCallback() {
124
+ // Clean up all effects
125
+ for (const dispose of this._dispose) dispose();
126
+ this._dispose = [];
127
+ this.onDestroy();
56
128
  }
57
129
 
58
- emit(event, data, options) {
59
- EventBus.emit(event, data, options);
60
- }
130
+ _applyStyles() {
131
+ const styles = this.constructor.styles;
132
+ if (!styles.length) return;
61
133
 
62
- trigger(event, data) {
63
- EventBus.trigger(event, data);
134
+ const sheet = new CSSStyleSheet();
135
+ sheet.replaceSync(styles.join('\n'));
136
+ this.shadowRoot.adoptedStyleSheets = [sheet];
64
137
  }
65
138
 
66
- navigate(path, args) {
67
- EventBus.trigger('okalit-route:navigate', { path, args });
68
- }
139
+ // --- Lifecycle hooks (override in subclasses) ---
69
140
 
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
- }
141
+ /** Called once when the element is first connected to the DOM */
142
+ onInit() {}
143
+
144
+ /** Called when a prop signal changes: { propName: { previous, current } } */
145
+ onChange(changes) {}
146
+
147
+ /** Called before each render */
148
+ onBeforeRender() {}
149
+
150
+ /** Called after each render */
151
+ onAfterRender() {}
87
152
 
88
- listen(event, callback) {
89
- const unsub = EventBus.listen(event, callback.bind(this));
90
- this._okalit_listen_unsubs.push(unsub);
153
+ /** Called when the element is disconnected from the DOM */
154
+ onDestroy() {}
155
+
156
+ /** Emit a custom event that crosses shadow DOM boundaries */
157
+ output(name, detail) {
158
+ this.dispatchEvent(new CustomEvent(name, {
159
+ detail,
160
+ bubbles: true,
161
+ composed: true,
162
+ }));
91
163
  }
92
164
 
93
- t(key, params) {
94
- return OkalitI18n.t(key, params);
165
+ /** Return a uhtml template */
166
+ render() {
167
+ return html``;
95
168
  }
96
169
 
97
- channel(event, { persist = 'memory', initialValue, onValue } = {}) {
98
- const currentValue = EventBus.get(event, { persist, fallback: initialValue });
99
- const s = signal(currentValue);
170
+ _initParams() {
171
+ const params = this.constructor.params;
172
+ if (!params.length) return;
100
173
 
101
- const handleValue = (val) => {
102
- s.set(val);
103
- if (typeof onValue === 'function') {
104
- onValue.call(this, val);
105
- }
106
- this.requestUpdate();
107
- };
174
+ const urlParams = new URLSearchParams(window.location.search);
108
175
 
109
- // Subscribe to channel changes
110
- const unsub = EventBus.on(event, handleValue, {
111
- persist,
112
- immediate: false,
113
- });
176
+ for (const paramDef of params) {
177
+ const [name, config] = Object.entries(paramDef)[0];
178
+ const raw = urlParams.get(name);
179
+ const value = raw !== null ? coerceValue(raw, config.type) : config.value;
114
180
 
115
- this._okalit_channel_unsubs.push(unsub);
181
+ this._signals[name] = signal(value);
116
182
 
117
- if (typeof onValue === 'function') {
118
- onValue.call(this, currentValue);
183
+ Object.defineProperty(this, name, {
184
+ get: () => this._signals[name],
185
+ set: (val) => {
186
+ if (val && typeof val === 'object' && 'value' in val && val === this._signals[name]) return;
187
+ this._signals[name].value = val;
188
+ },
189
+ configurable: true,
190
+ enumerable: true,
191
+ });
119
192
  }
193
+ }
194
+ }
120
195
 
121
- return {
122
- get: () => s.get(),
123
- set: (v) => {
124
- s.set(v);
125
- EventBus.emit(event, v, { persist });
126
- }
127
- };
196
+ function coerceValue(value, type) {
197
+ switch (type) {
198
+ case Number: return Number(value);
199
+ case Boolean: return value !== null && value !== 'false';
200
+ default: return value;
128
201
  }
129
202
  }
203
+
@@ -0,0 +1,177 @@
1
+ import { signal, effect, untracked } from 'uhtml';
2
+
3
+ // Global registry: one channel instance per name, shared across all components
4
+ const registry = new Map();
5
+
6
+ /**
7
+ * Define a reactive channel.
8
+ *
9
+ * @param {string} name - Unique channel identifier (e.g. 'ui:theme', 'ui:toast')
10
+ * @param {Object} options
11
+ * @param {*} options.initialValue - Default value (ignored if ephemeral)
12
+ * @param {boolean} options.ephemeral - If true, acts as event bus (no state stored)
13
+ * @param {'memory'|'local'|'session'} options.persist - Storage backend (default: 'memory')
14
+ * @param {'app'|'module'|'page'} options.scope - When to clear (default: 'app')
15
+ * @returns {Function} Factory function to use in static channels
16
+ */
17
+ export function defineChannel(name, options = {}) {
18
+ options.persist = options.persist || 'memory';
19
+ options.scope = options.scope || 'app';
20
+
21
+ return function (methodName) {
22
+ return {
23
+ _channelName: name,
24
+ _channelOptions: options,
25
+ _methodName: methodName || null,
26
+ };
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Clear all channels matching a given scope.
32
+ * Called by the router when navigation changes.
33
+ */
34
+ export function clearChannelsByScope(scope) {
35
+ for (const [name, channel] of registry) {
36
+ if (channel.ephemeral) continue;
37
+ if (channel._scope !== scope) continue;
38
+
39
+ // Reset signal to initial value
40
+ channel.signal.value = channel._initialValue;
41
+
42
+ // Remove persisted data
43
+ const storage = getStorage(channel._persist);
44
+ if (storage) {
45
+ storage.removeItem(`okalit:channel:${name}`);
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Get or create a shared channel instance from the global registry.
52
+ */
53
+ function getOrCreateChannel(name, options) {
54
+ if (registry.has(name)) return registry.get(name);
55
+
56
+ const subscribers = new Set();
57
+
58
+ if (options.ephemeral) {
59
+ const channel = {
60
+ ephemeral: true,
61
+ _scope: options.scope,
62
+ subscribers,
63
+ set(value) {
64
+ for (const fn of subscribers) fn(value);
65
+ },
66
+ get value() {
67
+ return undefined;
68
+ },
69
+ };
70
+ registry.set(name, channel);
71
+ return channel;
72
+ }
73
+
74
+ const initial = loadFromStorage(name, options) ?? options.initialValue;
75
+ const sig = signal(initial);
76
+
77
+ const channel = {
78
+ ephemeral: false,
79
+ _scope: options.scope,
80
+ _persist: options.persist,
81
+ _initialValue: options.initialValue,
82
+ signal: sig,
83
+ subscribers,
84
+ set(value) {
85
+ sig.value = value;
86
+ saveToStorage(name, value, options);
87
+ },
88
+ get value() {
89
+ return sig.value;
90
+ },
91
+ };
92
+
93
+ registry.set(name, channel);
94
+ return channel;
95
+ }
96
+
97
+ function loadFromStorage(name, options) {
98
+ const storage = getStorage(options.persist);
99
+ if (!storage) return undefined;
100
+
101
+ const raw = storage.getItem(`okalit:channel:${name}`);
102
+ if (raw === null) return undefined;
103
+
104
+ try {
105
+ return JSON.parse(raw);
106
+ } catch {
107
+ return raw;
108
+ }
109
+ }
110
+
111
+ function saveToStorage(name, value, options) {
112
+ const storage = getStorage(options.persist);
113
+ if (!storage) return;
114
+
115
+ storage.setItem(`okalit:channel:${name}`, JSON.stringify(value));
116
+ }
117
+
118
+ function getStorage(persist) {
119
+ if (persist === 'local') return localStorage;
120
+ if (persist === 'session') return sessionStorage;
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Initialize channels declared in static channels.
126
+ * Called from the Okalit base class constructor.
127
+ *
128
+ * @param {HTMLElement} instance - The component instance
129
+ * @returns {Function[]} Dispose functions to clean up subscriptions
130
+ */
131
+ export function initChannels(instance) {
132
+ const channelDefs = instance.constructor.channels;
133
+ if (!channelDefs) return [];
134
+
135
+ const disposers = [];
136
+ const deferredEffects = [];
137
+
138
+ // Phase 1: create all handles first so they're all available on `this`
139
+ for (const [key, config] of Object.entries(channelDefs)) {
140
+ const channel = getOrCreateChannel(config._channelName, config._channelOptions);
141
+
142
+ const handle = {
143
+ set: (value) => channel.set(value),
144
+ get value() {
145
+ return channel.value;
146
+ },
147
+ };
148
+
149
+ instance[key] = handle;
150
+
151
+ if (config._methodName) {
152
+ deferredEffects.push({ channel, methodName: config._methodName });
153
+ }
154
+ }
155
+
156
+ // Phase 2: subscribe effects after all handles exist
157
+ for (const { channel, methodName } of deferredEffects) {
158
+ const callback = (value) => {
159
+ if (typeof instance[methodName] === 'function') {
160
+ instance[methodName](value);
161
+ }
162
+ };
163
+
164
+ channel.subscribers.add(callback);
165
+ disposers.push(() => channel.subscribers.delete(callback));
166
+
167
+ if (!channel.ephemeral) {
168
+ const dispose = effect(() => {
169
+ const current = channel.signal.value;
170
+ untracked(() => callback(current));
171
+ });
172
+ disposers.push(dispose);
173
+ }
174
+ }
175
+
176
+ return disposers;
177
+ }