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