@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
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
|
-
|
|
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
|
-
##
|
|
20
|
+
## What it generates
|
|
17
21
|
|
|
18
|
-
- `new <app-name>`:
|
|
19
|
-
- `-g -c`:
|
|
20
|
-
- `-g -s`:
|
|
21
|
-
- `-g -m`:
|
|
22
|
-
- `-g --guard`:
|
|
23
|
-
- `-g --interceptor`:
|
|
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
|
-
##
|
|
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
|
|
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 "
|
|
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 `
|
|
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>${'${
|
|
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
|
|
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 {
|
|
287
|
+
return `import { OkalitGraphqlService, service } from "${coreAlias}";
|
|
289
288
|
|
|
290
289
|
@service("${serviceName}")
|
|
291
|
-
export class ${className} extends
|
|
290
|
+
export class ${className} extends OkalitGraphqlService {
|
|
292
291
|
constructor() {
|
|
293
292
|
super();
|
|
294
|
-
this.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
304
|
+
function buildServiceTemplate(_, coreAlias) {
|
|
305
305
|
return (baseName) => {
|
|
306
|
-
const
|
|
306
|
+
const className = `${toPascalCase(baseName)}Service`;
|
|
307
|
+
const serviceName = `${toCamelCase(baseName)}Service`;
|
|
307
308
|
|
|
308
|
-
return `
|
|
309
|
-
void path;
|
|
310
|
-
void args;
|
|
309
|
+
return `import { OkalitService, service } from "${coreAlias}";
|
|
311
310
|
|
|
312
|
-
|
|
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
|
|
326
|
+
function buildGuardTemplate(rawTarget) {
|
|
319
327
|
return (baseName) => {
|
|
320
|
-
const functionName = `${toCamelCase(baseName)}
|
|
328
|
+
const functionName = `${toCamelCase(baseName)}Guard`;
|
|
321
329
|
|
|
322
|
-
return `export async function ${functionName}(
|
|
323
|
-
return
|
|
330
|
+
return `export async function ${functionName}() {
|
|
331
|
+
return true;
|
|
324
332
|
}
|
|
325
333
|
`;
|
|
326
334
|
};
|
package/package.json
CHANGED
|
@@ -1,129 +1,203 @@
|
|
|
1
|
+
import { render, html, signal, computed, effect, batch } from 'uhtml';
|
|
2
|
+
import { initChannels } from './channel.js';
|
|
1
3
|
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
12
|
-
this.
|
|
19
|
+
// Create reactive signals for each declared prop
|
|
20
|
+
this._initProps();
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
67
|
+
this._applyStyles();
|
|
24
68
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
90
|
+
_syncAttributes() {
|
|
91
|
+
const propMap = this.constructor._propMap;
|
|
92
|
+
if (!propMap) return;
|
|
32
93
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
this.
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
130
|
+
_applyStyles() {
|
|
131
|
+
const styles = this.constructor.styles;
|
|
132
|
+
if (!styles.length) return;
|
|
61
133
|
|
|
62
|
-
|
|
63
|
-
|
|
134
|
+
const sheet = new CSSStyleSheet();
|
|
135
|
+
sheet.replaceSync(styles.join('\n'));
|
|
136
|
+
this.shadowRoot.adoptedStyleSheets = [sheet];
|
|
64
137
|
}
|
|
65
138
|
|
|
66
|
-
|
|
67
|
-
EventBus.trigger('okalit-route:navigate', { path, args });
|
|
68
|
-
}
|
|
139
|
+
// --- Lifecycle hooks (override in subclasses) ---
|
|
69
140
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
165
|
+
/** Return a uhtml template */
|
|
166
|
+
render() {
|
|
167
|
+
return html``;
|
|
95
168
|
}
|
|
96
169
|
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
170
|
+
_initParams() {
|
|
171
|
+
const params = this.constructor.params;
|
|
172
|
+
if (!params.length) return;
|
|
100
173
|
|
|
101
|
-
const
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
181
|
+
this._signals[name] = signal(value);
|
|
116
182
|
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|