@techninja/clearstack 0.3.8 → 0.3.10

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/bin/cli.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * Usage:
6
6
  * clearstack init [-y] [--mode fullstack|static] [--port 3000]
7
7
  * clearstack update
8
- * clearstack check [code|docs]
8
+ * clearstack check [code|docs|imports|lint|lint es|format|types|audit|all]
9
9
  * clearstack → interactive menu
10
10
  */
11
11
 
@@ -25,16 +25,21 @@ const yes = args.includes('-y') || args.includes('--yes');
25
25
 
26
26
  /** Show interactive menu. */
27
27
  async function interactive() {
28
- const { select } = await import('@inquirer/prompts');
29
- const action = await select({
30
- message: 'clearstack what do you want to do?',
31
- choices: [
32
- { name: 'Initialize a new project', value: 'init' },
33
- { name: 'Update spec docs + configs', value: 'update' },
34
- { name: 'Run spec compliance check', value: 'check' },
35
- ],
36
- });
37
- await run(action);
28
+ try {
29
+ const { select } = await import('@inquirer/prompts');
30
+ const action = await select({
31
+ message: 'clearstack — what do you want to do?',
32
+ choices: [
33
+ { name: 'Initialize a new project', value: 'init' },
34
+ { name: 'Update spec docs + configs', value: 'update' },
35
+ { name: 'Run spec compliance check', value: 'check' },
36
+ ],
37
+ });
38
+ await run(action);
39
+ } catch (e) {
40
+ if (e?.name === 'ExitPromptError') process.exit(0);
41
+ throw e;
42
+ }
38
43
  }
39
44
 
40
45
  /**
@@ -49,9 +54,9 @@ async function run(action) {
49
54
  const { update } = await import('../lib/update.js');
50
55
  await update(PKG_ROOT);
51
56
  } else if (action === 'check') {
52
- const sub = args.find((a) => a !== cmd && !a.startsWith('-'));
57
+ const subs = args.filter((a) => a !== cmd && !a.startsWith('-'));
53
58
  const { check } = await import('../lib/check.js');
54
- await check(process.cwd(), sub);
59
+ await check(process.cwd(), subs.join(' ') || undefined);
55
60
  } else {
56
61
  console.log('Usage: clearstack [init|update|check] [-y]');
57
62
  }
@@ -117,15 +117,15 @@ relative paths like `../../../utils/foo.js`.
117
117
 
118
118
  ### Available Prefixes
119
119
 
120
- | Prefix | Resolves to |
121
- | --------------- | ------------------------------ |
122
- | `#store/` | `/store/` |
123
- | `#utils/` | `/utils/` |
124
- | `#atoms/` | `/components/atoms/` |
125
- | `#molecules/` | `/components/molecules/` |
126
- | `#organisms/` | `/components/organisms/` |
127
- | `#templates/` | `/components/templates/` |
128
- | `#pages/` | `/pages/` |
120
+ | Prefix | Resolves to |
121
+ | ------------- | ------------------------ |
122
+ | `#store/` | `/store/` |
123
+ | `#utils/` | `/utils/` |
124
+ | `#atoms/` | `/components/atoms/` |
125
+ | `#molecules/` | `/components/molecules/` |
126
+ | `#organisms/` | `/components/organisms/` |
127
+ | `#templates/` | `/components/templates/` |
128
+ | `#pages/` | `/pages/` |
129
129
 
130
130
  ### Rules
131
131
 
@@ -33,6 +33,43 @@ export default define({
33
33
 
34
34
  Local state resets when the component disconnects from the DOM.
35
35
 
36
+ #### Property Defaults: Never Use `undefined` or `null`
37
+
38
+ Hybrids infers a property's type from its initial value. The router's cache
39
+ observer calls `.toString()` on property values during state transitions.
40
+ If a property is defined as `undefined` or later set to `null`, this crashes:
41
+
42
+ ```javascript
43
+ // ❌ BAD — undefined has no type info, null crashes .toString()
44
+ export default define({
45
+ tag: 'my-view',
46
+ _data: undefined, // no type info for hybrids to work with
47
+ // ...
48
+ });
49
+
50
+ // Later in a handler:
51
+ host._data = null; // TypeError: Cannot read properties of null (reading 'toString')
52
+ ```
53
+
54
+ Always provide a typed default value, and reset to the same type:
55
+
56
+ ```javascript
57
+ // ✅ GOOD — array default, connect no-op prevents hybrids from observing it
58
+ export default define({
59
+ tag: 'my-view',
60
+ _data: { value: [], connect: () => {} },
61
+ // ...
62
+ });
63
+
64
+ // Later in a handler:
65
+ host._data = []; // safe — same type as default
66
+ ```
67
+
68
+ The `connect: () => {}` no-op prevents hybrids from doing anything special
69
+ with the property — it's just internal state storage. This pattern is useful
70
+ for "private" properties that hold transient data (parsed results, file
71
+ contents, etc.) that shouldn't participate in the reactive render cycle.
72
+
36
73
  ### Shared State via Store
37
74
 
38
75
  For state shared across components or persisted beyond a component's lifetime,
@@ -180,6 +217,42 @@ tasks.map((task) =>
180
217
  This is especially important after batch operations (e.g. drag reorder)
181
218
  where multiple items are invalidated simultaneously.
182
219
 
220
+ #### Async `connect` on Array Properties
221
+
222
+ When a plain array property uses `connect` to load data asynchronously,
223
+ the render function fires **before** the async load completes. Even though
224
+ the default `value` is `[]`, hybrids may resolve the property as a
225
+ non-array during the pending phase.
226
+
227
+ Always guard with `Array.isArray()` before calling array methods:
228
+
229
+ ```javascript
230
+ export default define({
231
+ tag: 'my-view',
232
+ items: {
233
+ value: [],
234
+ connect: (host, _key, invalidate) => {
235
+ fetchItems().then((list) => {
236
+ host.items = list;
237
+ invalidate();
238
+ });
239
+ },
240
+ },
241
+ render: ({ items }) => html`
242
+ // ❌ BAD — items may not be an array yet when render fires
243
+ ${items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)} // ✅ GOOD — guard
244
+ before calling array methods
245
+ ${Array.isArray(items) && items.length > 0
246
+ ? items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)
247
+ : html``}
248
+ `,
249
+ });
250
+ ```
251
+
252
+ This applies to any property with `value: []` and an async `connect`.
253
+ The `connect` callback sets the value and calls `invalidate()`, but the
254
+ first render happens before that resolves.
255
+
183
256
  ---
184
257
 
185
258
  ## Routing
@@ -234,6 +307,71 @@ export default define({
234
307
  | Guarded route | `guard: () => isAuthenticated()` |
235
308
  | Dialog overlay | `dialog: true` on the view config |
236
309
 
310
+ #### Router Property Cache: Same-Value Writes Are No-Ops
311
+
312
+ The router caches component property values across navigations and page
313
+ reloads. When a view reconnects, its properties are restored from cache
314
+ **before** `connect` callbacks run.
315
+
316
+ This means if a `connect` callback loads data asynchronously and then sets
317
+ a property to the same value the cache already holds, hybrids sees no
318
+ change and skips the re-render:
319
+
320
+ ```javascript
321
+ // ❌ BAD — if router cache already has resultCount=22,
322
+ // setting it to 22 again is a no-op. No re-render happens.
323
+ connect: (host, _key, invalidate) => {
324
+ loadFromDB(host.userId).then((data) => {
325
+ populateMemoryCache(data); // side effect: fills an external object
326
+ host.resultCount = data.length; // may equal cached value → no re-render
327
+ invalidate();
328
+ });
329
+ },
330
+
331
+ // ✅ GOOD — reset to a sentinel value first, then set the real value.
332
+ // Hybrids sees 0 → 22, triggers re-render after data is loaded.
333
+ connect: (host, _key, invalidate) => {
334
+ loadFromDB(host.userId).then((data) => {
335
+ populateMemoryCache(data);
336
+ host.resultCount = 0; // force a change
337
+ host.resultCount = data.length; // now hybrids sees a real change
338
+ invalidate();
339
+ });
340
+ },
341
+ ```
342
+
343
+ This is especially important when render depends on external mutable state
344
+ (e.g. an in-memory cache object) that the property change is meant to
345
+ signal. Without the reset, the component renders with stale external state.
346
+
347
+ #### `router.backUrl()` Serializes All Parent Properties
348
+
349
+ `router.backUrl()` encodes the parent view's property values into query
350
+ params so hybrids can restore them when navigating back. If the parent has
351
+ properties holding complex objects (arrays of records, parsed data, etc.),
352
+ the URL becomes enormous — potentially megabytes — and the browser locks
353
+ up just rendering the `<a>` element.
354
+
355
+ The `connect: () => {}` no-op prevents hybrids from _observing_ a property,
356
+ but the router still serializes it. The only way to fully exclude a
357
+ property from URL serialization is to not define it on the routed view at
358
+ all (use a module-level variable or a separate store).
359
+
360
+ For child views that don't need to restore specific parent state, use a
361
+ direct href instead of `router.backUrl()`:
362
+
363
+ ```javascript
364
+ // ❌ BAD — serializes ALL parent properties into the href
365
+ // If parent has a 700K-item array, the URL is megabytes
366
+ html`<a href="${router.backUrl()}">← Back</a>`;
367
+
368
+ // ✅ GOOD — direct link, no serialization
369
+ html`<a href="/dashboard">← Back</a>`;
370
+ ```
371
+
372
+ Use `router.backUrl()` only when the parent view has simple scalar
373
+ properties (strings, numbers, booleans) that are cheap to serialize.
374
+
237
375
  ---
238
376
 
239
377
  ## Unified App State
package/lib/check.js CHANGED
@@ -1,6 +1,5 @@
1
1
  /**
2
2
  * Spec compliance checker — config, commands, and orchestration.
3
- * Core utilities live in spec-utils.js.
4
3
  * @module lib/check
5
4
  */
6
5
 
@@ -62,43 +61,74 @@ function buildCmds(runner) {
62
61
  /** @deprecated Use buildCmds() instead — kept for backward compat. */
63
62
  export const CMDS = buildCmds('npx');
64
63
 
65
- /**
66
- * Run the full spec compliance check (used by clearstack CLI).
67
- * @param {string} projectDir
68
- * @param {string} [scope]
69
- */
64
+ /** @typedef {{ key: string, name: string, parent?: string, run: () => boolean }} Check */
65
+
66
+ /** Build the unified checks array. Children have a `parent` key. */
67
+ export function buildChecks(dir, cfg, cmds) {
68
+ const js = () => countFiles(dir, ['.js'], cfg.ignore);
69
+ const css = () => countFiles(dir, ['.css'], cfg.ignore);
70
+ const md = () => countFiles(dir, ['.md'], cfg.ignore);
71
+ return [
72
+ { key: 'code', name: `Code (max ${cfg.codeMax} lines)`,
73
+ run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`) },
74
+ { key: 'docs', name: `Docs (max ${cfg.docsMax} lines)`,
75
+ run: () => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`) },
76
+ { key: 'imports', name: 'Import map aliases (no ../ imports)',
77
+ run: () => checkImports(dir, cfg.ignore, 'Import map aliases (no ../ imports)') },
78
+ { key: 'es', name: 'ESLint', parent: 'lint',
79
+ run: () => runCmd('ESLint', cmds.lint, dir, `${js()} files`) },
80
+ { key: 'css', name: 'Stylelint', parent: 'lint',
81
+ run: () => runCmd('Stylelint', cmds.stylelint, dir, `${css()} files`) },
82
+ { key: 'md', name: 'Markdown lint', parent: 'lint',
83
+ run: () => runCmd('Markdown', cmds.mdlint, dir, `${md()} files`) },
84
+ { key: 'prettier', name: 'Prettier', parent: 'format',
85
+ run: () => runCmd('Prettier', cmds.prettier, dir, `${js()} files`) },
86
+ { key: 'types', name: 'JSDoc types (tsc --checkJs)',
87
+ run: () => runCmd('JSDoc types', cmds.types, dir, `${js()} files`) },
88
+ { key: 'audit', name: 'Security audit',
89
+ run: () => runCmd('Security audit', cmds.audit, dir) },
90
+ ];
91
+ }
92
+
93
+ /** Resolve a scope like 'lint', 'lint es', or 'code' to runnable check(s). */
94
+ export function resolveChecks(checks, scope) {
95
+ const [first, second] = scope.split(/\s+/);
96
+ if (second) {
97
+ const match = checks.find((c) => c.parent === first && c.key === second);
98
+ return match ? [match] : null;
99
+ }
100
+ const children = checks.filter((c) => c.parent === first);
101
+ if (children.length) return children;
102
+ const exact = checks.find((c) => c.key === first && !c.parent);
103
+ return exact ? [exact] : null;
104
+ }
105
+
106
+ /** All unique parent keys. */
107
+ export function parentKeys(checks) {
108
+ return [...new Set(checks.filter((c) => c.parent).map((c) => c.parent))];
109
+ }
110
+
111
+ /** Run the full spec compliance check (used by clearstack CLI). */
70
112
  export async function check(projectDir, scope) {
71
113
  const cfg = loadConfig(projectDir);
72
114
  const cmds = buildCmds(detectRunner(projectDir));
115
+ const checks = buildChecks(projectDir, cfg, cmds);
73
116
 
74
- if (scope === 'code') {
75
- if (!checkFileLines(projectDir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`))
117
+ if (scope && scope !== 'all') {
118
+ const matched = resolveChecks(checks, scope);
119
+ if (!matched) {
120
+ console.log(`Unknown check: ${scope}`);
121
+ const tops = checks.filter((c) => !c.parent).map((c) => c.key);
122
+ console.log(`Available: ${[...tops, ...parentKeys(checks)].join(', ')}`);
76
123
  process.exit(1);
124
+ }
125
+ const ok = matched.every((c) => c.run());
126
+ if (!ok) process.exit(1);
77
127
  return;
78
128
  }
79
- if (scope === 'docs') {
80
- if (!checkFileLines(projectDir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`))
81
- process.exit(1);
82
- return;
83
- }
84
-
85
- const jsFiles = countFiles(projectDir, ['.js'], cfg.ignore);
86
- const cssFiles = countFiles(projectDir, ['.css'], cfg.ignore);
87
- const mdFiles = countFiles(projectDir, ['.md'], cfg.ignore);
88
129
 
89
130
  console.log('Running spec compliance check...\n');
90
- const results = [
91
- checkFileLines(projectDir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`),
92
- checkFileLines(projectDir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`),
93
- checkImports(projectDir, cfg.ignore, 'Import map aliases (no ../ imports)'),
94
- runCmd('ESLint', cmds.lint, projectDir, `${jsFiles} files`),
95
- runCmd('Stylelint', cmds.stylelint, projectDir, `${cssFiles} files`),
96
- runCmd('Prettier', cmds.prettier, projectDir, `${jsFiles} files`),
97
- runCmd('Markdown', cmds.mdlint, projectDir, `${mdFiles} files`),
98
- runCmd('JSDoc types', cmds.types, projectDir, `${jsFiles} files`),
99
- runCmd('Security audit', cmds.audit, projectDir),
100
- ];
101
-
131
+ const results = checks.map((c) => c.run());
102
132
  const passed = results.filter(Boolean).length;
103
133
  console.log(`\n${'='.repeat(40)}`);
104
134
  console.log(`${passed}/${results.length} checks passed.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "type": "module",
5
5
  "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
6
  "bin": {
@@ -117,15 +117,15 @@ relative paths like `../../../utils/foo.js`.
117
117
 
118
118
  ### Available Prefixes
119
119
 
120
- | Prefix | Resolves to |
121
- | --------------- | ------------------------------ |
122
- | `#store/` | `/store/` |
123
- | `#utils/` | `/utils/` |
124
- | `#atoms/` | `/components/atoms/` |
125
- | `#molecules/` | `/components/molecules/` |
126
- | `#organisms/` | `/components/organisms/` |
127
- | `#templates/` | `/components/templates/` |
128
- | `#pages/` | `/pages/` |
120
+ | Prefix | Resolves to |
121
+ | ------------- | ------------------------ |
122
+ | `#store/` | `/store/` |
123
+ | `#utils/` | `/utils/` |
124
+ | `#atoms/` | `/components/atoms/` |
125
+ | `#molecules/` | `/components/molecules/` |
126
+ | `#organisms/` | `/components/organisms/` |
127
+ | `#templates/` | `/components/templates/` |
128
+ | `#pages/` | `/pages/` |
129
129
 
130
130
  ### Rules
131
131
 
@@ -33,6 +33,43 @@ export default define({
33
33
 
34
34
  Local state resets when the component disconnects from the DOM.
35
35
 
36
+ #### Property Defaults: Never Use `undefined` or `null`
37
+
38
+ Hybrids infers a property's type from its initial value. The router's cache
39
+ observer calls `.toString()` on property values during state transitions.
40
+ If a property is defined as `undefined` or later set to `null`, this crashes:
41
+
42
+ ```javascript
43
+ // ❌ BAD — undefined has no type info, null crashes .toString()
44
+ export default define({
45
+ tag: 'my-view',
46
+ _data: undefined, // no type info for hybrids to work with
47
+ // ...
48
+ });
49
+
50
+ // Later in a handler:
51
+ host._data = null; // TypeError: Cannot read properties of null (reading 'toString')
52
+ ```
53
+
54
+ Always provide a typed default value, and reset to the same type:
55
+
56
+ ```javascript
57
+ // ✅ GOOD — array default, connect no-op prevents hybrids from observing it
58
+ export default define({
59
+ tag: 'my-view',
60
+ _data: { value: [], connect: () => {} },
61
+ // ...
62
+ });
63
+
64
+ // Later in a handler:
65
+ host._data = []; // safe — same type as default
66
+ ```
67
+
68
+ The `connect: () => {}` no-op prevents hybrids from doing anything special
69
+ with the property — it's just internal state storage. This pattern is useful
70
+ for "private" properties that hold transient data (parsed results, file
71
+ contents, etc.) that shouldn't participate in the reactive render cycle.
72
+
36
73
  ### Shared State via Store
37
74
 
38
75
  For state shared across components or persisted beyond a component's lifetime,
@@ -180,6 +217,42 @@ tasks.map((task) =>
180
217
  This is especially important after batch operations (e.g. drag reorder)
181
218
  where multiple items are invalidated simultaneously.
182
219
 
220
+ #### Async `connect` on Array Properties
221
+
222
+ When a plain array property uses `connect` to load data asynchronously,
223
+ the render function fires **before** the async load completes. Even though
224
+ the default `value` is `[]`, hybrids may resolve the property as a
225
+ non-array during the pending phase.
226
+
227
+ Always guard with `Array.isArray()` before calling array methods:
228
+
229
+ ```javascript
230
+ export default define({
231
+ tag: 'my-view',
232
+ items: {
233
+ value: [],
234
+ connect: (host, _key, invalidate) => {
235
+ fetchItems().then((list) => {
236
+ host.items = list;
237
+ invalidate();
238
+ });
239
+ },
240
+ },
241
+ render: ({ items }) => html`
242
+ // ❌ BAD — items may not be an array yet when render fires
243
+ ${items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)} // ✅ GOOD — guard
244
+ before calling array methods
245
+ ${Array.isArray(items) && items.length > 0
246
+ ? items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)
247
+ : html``}
248
+ `,
249
+ });
250
+ ```
251
+
252
+ This applies to any property with `value: []` and an async `connect`.
253
+ The `connect` callback sets the value and calls `invalidate()`, but the
254
+ first render happens before that resolves.
255
+
183
256
  ---
184
257
 
185
258
  ## Routing
@@ -234,6 +307,71 @@ export default define({
234
307
  | Guarded route | `guard: () => isAuthenticated()` |
235
308
  | Dialog overlay | `dialog: true` on the view config |
236
309
 
310
+ #### Router Property Cache: Same-Value Writes Are No-Ops
311
+
312
+ The router caches component property values across navigations and page
313
+ reloads. When a view reconnects, its properties are restored from cache
314
+ **before** `connect` callbacks run.
315
+
316
+ This means if a `connect` callback loads data asynchronously and then sets
317
+ a property to the same value the cache already holds, hybrids sees no
318
+ change and skips the re-render:
319
+
320
+ ```javascript
321
+ // ❌ BAD — if router cache already has resultCount=22,
322
+ // setting it to 22 again is a no-op. No re-render happens.
323
+ connect: (host, _key, invalidate) => {
324
+ loadFromDB(host.userId).then((data) => {
325
+ populateMemoryCache(data); // side effect: fills an external object
326
+ host.resultCount = data.length; // may equal cached value → no re-render
327
+ invalidate();
328
+ });
329
+ },
330
+
331
+ // ✅ GOOD — reset to a sentinel value first, then set the real value.
332
+ // Hybrids sees 0 → 22, triggers re-render after data is loaded.
333
+ connect: (host, _key, invalidate) => {
334
+ loadFromDB(host.userId).then((data) => {
335
+ populateMemoryCache(data);
336
+ host.resultCount = 0; // force a change
337
+ host.resultCount = data.length; // now hybrids sees a real change
338
+ invalidate();
339
+ });
340
+ },
341
+ ```
342
+
343
+ This is especially important when render depends on external mutable state
344
+ (e.g. an in-memory cache object) that the property change is meant to
345
+ signal. Without the reset, the component renders with stale external state.
346
+
347
+ #### `router.backUrl()` Serializes All Parent Properties
348
+
349
+ `router.backUrl()` encodes the parent view's property values into query
350
+ params so hybrids can restore them when navigating back. If the parent has
351
+ properties holding complex objects (arrays of records, parsed data, etc.),
352
+ the URL becomes enormous — potentially megabytes — and the browser locks
353
+ up just rendering the `<a>` element.
354
+
355
+ The `connect: () => {}` no-op prevents hybrids from _observing_ a property,
356
+ but the router still serializes it. The only way to fully exclude a
357
+ property from URL serialization is to not define it on the routed view at
358
+ all (use a module-level variable or a separate store).
359
+
360
+ For child views that don't need to restore specific parent state, use a
361
+ direct href instead of `router.backUrl()`:
362
+
363
+ ```javascript
364
+ // ❌ BAD — serializes ALL parent properties into the href
365
+ // If parent has a 700K-item array, the URL is megabytes
366
+ html`<a href="${router.backUrl()}">← Back</a>`;
367
+
368
+ // ✅ GOOD — direct link, no serialization
369
+ html`<a href="/dashboard">← Back</a>`;
370
+ ```
371
+
372
+ Use `router.backUrl()` only when the parent view has simple scalar
373
+ properties (strings, numbers, booleans) that are cheap to serialize.
374
+
237
375
  ---
238
376
 
239
377
  ## Unified App State