@webjsdev/server 0.8.31 → 0.8.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.31",
3
+ "version": "0.8.32",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/check.js CHANGED
@@ -74,9 +74,14 @@ export const RULES = [
74
74
  'A custom-element tag name must be registered exactly once across the app. Two `Class.register(\'tag\')` / `customElements.define(\'tag\', …)` calls for the SAME tag resolve INCONSISTENTLY at runtime: SSR overwrites the registry (last registration wins) while the browser keeps the first native upgrade, so the rendered element and the webjs registry disagree. Rename one tag.',
75
75
  },
76
76
  {
77
- name: 'reactive-props-use-declare',
77
+ name: 'no-static-properties',
78
78
  description:
79
- 'Reactive properties listed in `static properties = { … }` must be typed with `declare propName: Type` (no value), and have their default set in `constructor()`. Plain class-field initializers (`prop = value` or `prop: Type = value`) compile to Object.defineProperty *after* super() under modern class-field semantics, clobbering the framework\'s reactive accessor and silently breaking re-renders.',
79
+ 'Reactive properties must be declared via the `extends WebComponent({ … })` factory, never a hand-written `static properties = { … }` field in the class body. The factory types each field for you (no `declare` needed) and the runtime throws on a direct `static properties`. Migrate `class X extends WebComponent { static properties = { count: { type: Number } } }` to `class X extends WebComponent({ count: Number })`; use the `prop()` helper for options (`prop(Number, { reflect: true })`) and set defaults via the `default` option or in the constructor.',
80
+ },
81
+ {
82
+ name: 'reactive-props-no-class-field',
83
+ description:
84
+ 'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field initializer (`count = 0` or `count: number = 0`) in the class body. Under modern class-field semantics that initializer runs Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default via the `default` property option (`prop(Number, { default: 0 })`) or by assigning in the constructor after super().',
80
85
  },
81
86
  {
82
87
  name: 'shell-in-non-root-layout',
@@ -166,58 +171,6 @@ function isComponentFile(relPath) {
166
171
  return segments.includes('components');
167
172
  }
168
173
 
169
- /**
170
- * Find every `<key>:` entry inside the first `static properties = { … }`
171
- * literal in `classBody`. Returns the bare property names: the keys
172
- * we'll then look up as class fields.
173
- *
174
- * @param {string} classBody
175
- * @returns {Set<string>}
176
- */
177
- function extractStaticPropertyNames(classBody) {
178
- /** @type {Set<string>} */
179
- const names = new Set();
180
- const m = /static\s+properties\s*=\s*\{/.exec(classBody);
181
- if (!m) return names;
182
- const objStart = m.index + m[0].length;
183
- const objEnd = matchClosingBrace(classBody, objStart);
184
- if (objEnd === -1) return names;
185
- const obj = classBody.slice(objStart, objEnd);
186
- // Match keys at the top level of the object literal. A nested `{ … }`
187
- // (the per-property declaration) is skipped via brace counting.
188
- let i = 0;
189
- while (i < obj.length) {
190
- // Skip whitespace and commas.
191
- while (i < obj.length && /[\s,]/.test(obj[i])) i++;
192
- if (i >= obj.length) break;
193
- // Read the identifier or string-literal key.
194
- let key = '';
195
- if (obj[i] === '"' || obj[i] === "'") {
196
- const quote = obj[i++];
197
- while (i < obj.length && obj[i] !== quote) { key += obj[i++]; }
198
- i++; // closing quote
199
- } else {
200
- while (i < obj.length && /[A-Za-z0-9_$]/.test(obj[i])) key += obj[i++];
201
- }
202
- // Skip whitespace, then expect `:`.
203
- while (i < obj.length && /\s/.test(obj[i])) i++;
204
- if (obj[i] !== ':') break;
205
- i++;
206
- // Skip whitespace, then skip the value (either a `{ … }` literal or
207
- // a single token like `String`).
208
- while (i < obj.length && /\s/.test(obj[i])) i++;
209
- if (obj[i] === '{') {
210
- const valEnd = matchClosingBrace(obj, i + 1);
211
- if (valEnd === -1) break;
212
- i = valEnd + 1;
213
- } else {
214
- while (i < obj.length && obj[i] !== ',' && obj[i] !== '}') i++;
215
- }
216
- if (key) names.add(key);
217
- }
218
- return names;
219
- }
220
-
221
174
  /**
222
175
  * Scan a class body for class-field initializers naming any of `props`.
223
176
  * "Class-field" means: at the top of the class body (brace depth 0
@@ -440,23 +393,47 @@ export async function checkConventions(appDir) {
440
393
  }
441
394
  }
442
395
 
443
- // --- Rule: reactive-props-use-declare ---
396
+ // --- Rule: no-static-properties ---
397
+ // A hand-written `static properties = { … }` in a WebComponent class body is
398
+ // no longer supported: reactive properties are declared via the
399
+ // `extends WebComponent({ … })` factory (the runtime throws on a direct
400
+ // `static properties`). Flag it statically so the editor catches it before
401
+ // the page 500s.
444
402
  {
445
403
  for (const { rel, scan } of files) {
446
- // Use redacted source so test-fixture-style strings like
447
- // `class X extends WebComponent { x = 0 }` inside template
448
- // literals don't trip the rule. Real declarations live at
404
+ // Use redacted source so fixture-style strings like
405
+ // `class X extends WebComponent { static properties = {…} }` inside
406
+ // template literals don't trip the rule. Real declarations live at
449
407
  // top-level code where the redactor leaves them alone.
450
408
  if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
451
- for (const body of extractWebComponentClassBodies(scan)) {
452
- const propNames = extractStaticPropertyNames(body);
453
- if (propNames.size === 0) continue;
454
- for (const bad of findFieldInitializers(body, propNames)) {
409
+ for (const { body } of extractWebComponentClassBodies(scan)) {
410
+ if (!/static\s+properties\s*=\s*\{/.test(body)) continue;
411
+ violations.push({
412
+ rule: 'no-static-properties',
413
+ file: rel,
414
+ message:
415
+ '`static properties = { … }` is no longer supported; declare reactive properties via the `extends WebComponent({ … })` factory instead.',
416
+ fix: 'Move the properties into the factory call: `class X extends WebComponent({ count: Number })`. Use `prop(Number, { reflect: true })` for options and set defaults via the `default` option or the constructor. Delete the `static properties` block and any `declare` fields for those props.',
417
+ });
418
+ }
419
+ }
420
+ }
421
+
422
+ // --- Rule: reactive-props-no-class-field ---
423
+ // A reactive property declared via the factory must not also carry a plain
424
+ // class-field initializer: it runs Object.defineProperty after super() and
425
+ // clobbers the framework's reactive accessor (silent broken re-renders).
426
+ {
427
+ for (const { rel, scan } of files) {
428
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
429
+ for (const { body, factoryProps } of extractWebComponentClassBodies(scan)) {
430
+ if (factoryProps.size === 0) continue;
431
+ for (const bad of findFieldInitializers(body, factoryProps)) {
455
432
  violations.push({
456
- rule: 'reactive-props-use-declare',
433
+ rule: 'reactive-props-no-class-field',
457
434
  file: rel,
458
435
  message: `Reactive prop \`${bad}\` uses a class-field initializer; this clobbers the framework's reactive accessor under modern class-field semantics.`,
459
- fix: `Replace with \`declare ${bad}: <Type>;\` and set the default inside \`constructor()\` after \`super()\`.`,
436
+ fix: `Remove the class-field initializer and set the default via the \`default\` option (\`prop(<Type>, { default: <value> })\`) or by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
460
437
  });
461
438
  }
462
439
  }
@@ -473,7 +450,7 @@ export async function checkConventions(appDir) {
473
450
  {
474
451
  for (const { rel, scan } of files) {
475
452
  if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
476
- for (const body of extractWebComponentClassBodies(scan)) {
453
+ for (const { body } of extractWebComponentClassBodies(scan)) {
477
454
  for (const method of ['constructor', 'willUpdate', 'render']) {
478
455
  const code = methodBodyOf(body, method);
479
456
  if (!code) continue;
@@ -661,7 +638,7 @@ export async function checkConventions(appDir) {
661
638
  // modifiers immediately followed by an identifier inside the
662
639
  // constructor's parameter list.
663
640
  regex: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/,
664
- fix: 'Replace `constructor(public x: number)` with `x: number; constructor(x: number) { this.x = x; }`. The reactive-props-use-declare rule has the framework-specific shape: `declare x: number;` (no value) plus the assignment in the constructor body.',
641
+ fix: 'Replace `constructor(public x: number)` with `x: number; constructor(x: number) { this.x = x; }`.',
665
642
  },
666
643
  {
667
644
  name: 'import = require',
@@ -416,7 +416,7 @@ export function analyzeComponentSource(src) {
416
416
  return { interactive: true, reason: 'no parseable WebComponent class body' };
417
417
  }
418
418
 
419
- for (const body of bodies) {
419
+ for (const { body, factoryArg } of bodies) {
420
420
  // Shadow DOM always ships (#474). Declarative Shadow DOM attaches ONLY
421
421
  // during HTML parsing; an elided component that arrives via a client DOM
422
422
  // insertion (a soft-nav swap, a streamed <webjs-suspense> boundary's
@@ -447,7 +447,7 @@ export function analyzeComponentSource(src) {
447
447
  return { interactive: true, reason: `calls '${call}'` };
448
448
  }
449
449
  }
450
- if (hasNonStateReactiveProperty(body)) {
450
+ if (hasNonStateReactiveProperty(body) || hasNonStateFactoryProperty(factoryArg)) {
451
451
  return {
452
452
  interactive: true,
453
453
  reason: 'declares a reactive property that is not { state: true }',
@@ -571,6 +571,36 @@ function hasNonStateReactiveProperty(classBody) {
571
571
  return false;
572
572
  }
573
573
 
574
+ /**
575
+ * True if the class factory argument declares any property that is NOT
576
+ * marked `{ state: true }`.
577
+ *
578
+ * @param {string} factoryArg
579
+ * @returns {boolean}
580
+ */
581
+ function hasNonStateFactoryProperty(factoryArg) {
582
+ if (!factoryArg) return false;
583
+ const objStart = factoryArg.indexOf('{');
584
+ if (objStart === -1) return false;
585
+ const objEnd = matchClosingBrace(factoryArg, objStart);
586
+ if (objEnd === -1) return true;
587
+ const obj = factoryArg.slice(objStart + 1, objEnd);
588
+ if (/\.\.\./.test(obj)) return true; // spread
589
+ for (const entry of topLevelPropertyValues(obj)) {
590
+ if (entry.startsWith('{')) {
591
+ const code = entry
592
+ .replace(/'[^'\n]*'/g, "''")
593
+ .replace(/"[^"\n]*"/g, '""')
594
+ .replace(/`[^`]*`/g, '``');
595
+ if (!/\bstate\s*:\s*true\b/.test(code)) return true;
596
+ } else {
597
+ // Shorthand like `count: Number` rides an attribute, not state
598
+ return true;
599
+ }
600
+ }
601
+ return false;
602
+ }
603
+
574
604
  /**
575
605
  * Yield the trimmed VALUE text of each top-level `key: value` entry in a
576
606
  * properties object body, splitting on depth-0 commas and respecting
package/src/js-scan.js CHANGED
@@ -347,16 +347,127 @@ export function maskComments(src) {
347
347
  */
348
348
  export function extractWebComponentClassBodies(content) {
349
349
  const bodies = [];
350
- const re = /class\s+\w+\s+extends\s+WebComponent\s*\{/g;
350
+ const re = /class\s+\w+\s+extends\s+WebComponent/g;
351
351
  let m;
352
352
  while ((m = re.exec(content)) !== null) {
353
- const bodyStart = m.index + m[0].length;
354
- const end = matchClosingBrace(content, bodyStart);
355
- if (end !== -1) bodies.push(content.slice(bodyStart, end));
353
+ let i = m.index + m[0].length;
354
+ while (i < content.length && /\s/.test(content[i])) i++;
355
+ if (i >= content.length) continue;
356
+
357
+ let factoryArg = '';
358
+ const factoryProps = new Set();
359
+ if (content[i] === '(') {
360
+ const closeParen = matchClosingParenthesis(content, i + 1);
361
+ if (closeParen === -1) continue;
362
+ factoryArg = content.slice(i + 1, closeParen);
363
+
364
+ const objStart = factoryArg.indexOf('{');
365
+ if (objStart !== -1) {
366
+ const objEnd = matchClosingBrace(factoryArg, objStart + 1);
367
+ if (objEnd !== -1) {
368
+ const objContent = factoryArg.slice(objStart + 1, objEnd);
369
+ parsePropsFromObjectLiteral(objContent, factoryProps);
370
+ }
371
+ }
372
+ i = closeParen + 1;
373
+ while (i < content.length && /\s/.test(content[i])) i++;
374
+ }
375
+ if (content[i] === '{') {
376
+ const bodyStart = i + 1;
377
+ const end = matchClosingBrace(content, bodyStart);
378
+ if (end !== -1) {
379
+ bodies.push({
380
+ body: content.slice(bodyStart, end),
381
+ factoryProps,
382
+ factoryArg
383
+ });
384
+ }
385
+ }
356
386
  }
357
387
  return bodies;
358
388
  }
359
389
 
390
+ /**
391
+ * Extract properties from an object literal block.
392
+ *
393
+ * @param {string} obj
394
+ * @param {Set<string>} names
395
+ */
396
+ export function parsePropsFromObjectLiteral(obj, names) {
397
+ let i = 0;
398
+ while (i < obj.length) {
399
+ while (i < obj.length && /[\s,]/.test(obj[i])) i++;
400
+ if (i >= obj.length) break;
401
+ let key = '';
402
+ if (obj[i] === '"' || obj[i] === "'") {
403
+ const quote = obj[i++];
404
+ while (i < obj.length && obj[i] !== quote) { key += obj[i++]; }
405
+ i++; // closing quote
406
+ } else {
407
+ while (i < obj.length && /[A-Za-z0-9_$]/.test(obj[i])) key += obj[i++];
408
+ }
409
+ while (i < obj.length && /\s/.test(obj[i])) i++;
410
+ if (obj[i] !== ':') break;
411
+ i++;
412
+ while (i < obj.length && /\s/.test(obj[i])) i++;
413
+ if (obj[i] === '{') {
414
+ const valEnd = matchClosingBrace(obj, i + 1);
415
+ if (valEnd === -1) break;
416
+ i = valEnd + 1;
417
+ } else {
418
+ while (i < obj.length && obj[i] !== ',' && obj[i] !== '}') i++;
419
+ }
420
+ if (key) names.add(key);
421
+ }
422
+ }
423
+
424
+
425
+ /**
426
+ * Walk forward from `start` (just after an opening `(`) and return the
427
+ * index of the matching `)`. Tracks string/template-literal state so
428
+ * `)` inside strings or templates does not decrement depth.
429
+ * Returns -1 if no balanced parenthesis is found.
430
+ *
431
+ * @param {string} s
432
+ * @param {number} start
433
+ */
434
+ export function matchClosingParenthesis(s, start) {
435
+ let depth = 1;
436
+ let i = start;
437
+ let str = ''; // '', "'", '"', or backtick
438
+ while (i < s.length) {
439
+ const c = s[i];
440
+ if (str) {
441
+ if (c === '\\') { i += 2; continue; }
442
+ if (c === str) str = '';
443
+ else if (str === '`' && c === '$' && s[i + 1] === '{') {
444
+ const closeBrace = matchClosingBrace(s, i + 2);
445
+ if (closeBrace === -1) return -1;
446
+ i = closeBrace + 1;
447
+ continue;
448
+ }
449
+ i++;
450
+ continue;
451
+ }
452
+ if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
453
+ if (c === '/' && s[i + 1] === '/') { // line comment
454
+ while (i < s.length && s[i] !== '\n') i++;
455
+ continue;
456
+ }
457
+ if (c === '/' && s[i + 1] === '*') { // block comment
458
+ i += 2;
459
+ while (i < s.length && !(s[i] === '*' && s[i + 1] === '/')) i++;
460
+ i += 2;
461
+ continue;
462
+ }
463
+ if (c === '(') depth++;
464
+ else if (c === ')') { depth--; if (depth === 0) return i; }
465
+ i++;
466
+ }
467
+ return -1;
468
+ }
469
+
470
+
360
471
  /**
361
472
  * Walk forward from `start` (just after an opening `{`) and return the
362
473
  * index of the matching `}`. Tracks string/template-literal state so