@webjsdev/server 0.8.91 → 0.10.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 (105) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +461 -102
  3. package/index.js +9 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-input.js +208 -0
  7. package/src/action-params.js +156 -0
  8. package/src/action-route.js +8 -9
  9. package/src/action-seed.js +44 -5
  10. package/src/actions.js +112 -15
  11. package/src/api.js +67 -10
  12. package/src/app-layout.js +133 -0
  13. package/src/asset-hash.js +82 -49
  14. package/src/auth.js +612 -108
  15. package/src/browser-entries.js +2 -17
  16. package/src/build-epoch.js +1 -1
  17. package/src/cache-tags.js +5 -1
  18. package/src/check/helpers.js +85 -0
  19. package/src/check/rules-actions.js +37 -1
  20. package/src/check/rules-components.js +133 -28
  21. package/src/check/rules-imports.js +175 -4
  22. package/src/check/rules-route-modules.js +200 -0
  23. package/src/check/rules-routes.js +55 -0
  24. package/src/check/rules-routing.js +143 -95
  25. package/src/check/rules-typescript.js +24 -2
  26. package/src/check/rules.js +109 -9
  27. package/src/check/runner-support.js +53 -171
  28. package/src/check/runner.js +24 -6
  29. package/src/client-signals.js +171 -0
  30. package/src/component-scanner.js +22 -16
  31. package/src/context.js +152 -16
  32. package/src/cookie-store.js +262 -0
  33. package/src/core-exports.js +190 -0
  34. package/src/dev/bun-app-source.js +95 -5
  35. package/src/dev/config.js +7 -51
  36. package/src/dev/dev-service-worker.js +48 -0
  37. package/src/dev/dev-status.js +122 -0
  38. package/src/dev/esm-hot-hooks.js +373 -0
  39. package/src/dev/handler.js +632 -155
  40. package/src/dev/helpers.js +127 -96
  41. package/src/dev/hot-host.js +33 -6
  42. package/src/dev/poke-state.js +110 -0
  43. package/src/dev/serve.js +191 -133
  44. package/src/dev/server.js +109 -37
  45. package/src/dev/specifier-versions.js +125 -0
  46. package/src/dev/watch-ignore.js +49 -4
  47. package/src/dev/watch-poll.js +78 -0
  48. package/src/dev/watch-tree.js +59 -22
  49. package/src/dev-classify.js +59 -26
  50. package/src/dev-error.js +104 -3
  51. package/src/dev-hot-classify.js +291 -0
  52. package/src/dev-hot-client.js +245 -0
  53. package/src/dev-import.js +22 -5
  54. package/src/dev-overlay.js +26 -0
  55. package/src/dev-ready-probe.js +69 -0
  56. package/src/dev-regenerate.js +322 -78
  57. package/src/dev-reload-hold.js +49 -20
  58. package/src/dev-reload-reason.js +61 -0
  59. package/src/dev-reload-worker.js +62 -13
  60. package/src/dev-secret.js +73 -0
  61. package/src/dev-source-locations-hook.js +4 -1
  62. package/src/dev-source-locations.js +33 -2
  63. package/src/dev-styles.js +64 -6
  64. package/src/dev.js +0 -1
  65. package/src/env-files.js +124 -0
  66. package/src/file-storage.js +2 -2
  67. package/src/foreign-imports.js +228 -0
  68. package/src/form-action-identity.js +52 -25
  69. package/src/form-dispatch.js +150 -69
  70. package/src/html-cache.js +35 -4
  71. package/src/importmap.js +2 -2
  72. package/src/instrumentation.js +59 -3
  73. package/src/js-scan.js +5 -4
  74. package/src/jwe.js +155 -0
  75. package/src/lazy-components.js +166 -0
  76. package/src/listener-core.js +4 -2
  77. package/src/lit-hints.js +172 -0
  78. package/src/middleware-load.js +248 -0
  79. package/src/module-graph.js +73 -47
  80. package/src/module-side-effect.js +284 -0
  81. package/src/public-files.js +73 -0
  82. package/src/redirect-location.js +19 -0
  83. package/src/redirects.js +9 -1
  84. package/src/route-frontier.js +151 -0
  85. package/src/route-report.js +130 -0
  86. package/src/router.js +120 -13
  87. package/src/session.js +32 -10
  88. package/src/ssr/client-router-flag.js +1 -1
  89. package/src/ssr/document.js +49 -17
  90. package/src/ssr/head.js +228 -25
  91. package/src/ssr/preloads.js +31 -45
  92. package/src/ssr/render.js +243 -55
  93. package/src/ssr/responses.js +51 -4
  94. package/src/ssr/shipped-modules.js +52 -16
  95. package/src/testing.d.ts +31 -2
  96. package/src/testing.js +85 -17
  97. package/src/thenable-params.js +26 -0
  98. package/src/vendor/jspm.js +12 -1
  99. package/src/vendor/pins.js +9 -8
  100. package/src/vendor/scanner.js +14 -9
  101. package/src/vendor.js +5 -3
  102. package/webjs-config.schema.json +3 -8
  103. package/src/component-elision.js +0 -1852
  104. package/src/elision-differential.js +0 -101
  105. package/src/elision-report.js +0 -193
@@ -13,15 +13,14 @@
13
13
  * does, without booting a server.
14
14
  *
15
15
  * Entries:
16
- * - page / layout (both re-run on the client for hydration)
17
- * - the error / loading / not-found / forbidden / unauthorized boundaries
18
- * - the two root-only boundaries (global-error, global-not-found)
19
16
  * - instrumentation-client (imported first in the boot)
20
17
  * - every discovered component (a `static lazy` one is fetched by the lazy
21
18
  * loader rather than imported by a page, so the component scan is an entry
22
19
  * source in its own right)
23
20
  *
24
21
  * NOT entries, because the browser never fetches them as a module:
22
+ * - pages, layouts and every boundary: they run only on the server, and the
23
+ * boot imports the components they render instead (#1686)
25
24
  * - route.{js,ts} (API handlers) and middleware.{js,ts}
26
25
  * - metadata routes (sitemap.js, robots.js, manifest.js, …)
27
26
  * - .server.{js,ts} files (the browser gets a stub, not the source)
@@ -38,20 +37,6 @@
38
37
  export function browserEntryFiles(routeTable, components) {
39
38
  /** @type {Set<string>} */
40
39
  const entries = new Set();
41
- for (const page of routeTable.pages) {
42
- if (page.file) entries.add(page.file);
43
- for (const f of page.layouts || []) entries.add(f);
44
- for (const f of page.errors || []) entries.add(f);
45
- for (const f of page.loadings || []) entries.add(f);
46
- for (const f of page.forbiddens || []) entries.add(f);
47
- for (const f of page.unauthorizeds || []) entries.add(f);
48
- }
49
- if (routeTable.notFound) entries.add(routeTable.notFound);
50
- if (routeTable.notFounds) {
51
- for (const f of routeTable.notFounds.values()) entries.add(f);
52
- }
53
- if (routeTable.globalError) entries.add(routeTable.globalError);
54
- if (routeTable.globalNotFound) entries.add(routeTable.globalNotFound);
55
40
  // instrumentation-client is browser-bound (imported first in the boot), so it
56
41
  // must be servable through the gate.
57
42
  if (routeTable.instrumentationClient) entries.add(routeTable.instrumentationClient);
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * "Build" here is the word in its no-build sense, the one `data-webjs-build`
6
6
  * already uses: the analysis generation of a RUNNING process (its route table,
7
- * module graph, elision verdict, importmap, asset hashes), which changes on a
7
+ * module graph, component frontiers, importmap, asset hashes), which changes on a
8
8
  * deploy or a dev rebuild. Nothing is compiled, bundled, or written to disk.
9
9
  *
10
10
  * A production page render recomputed several values on every request that
package/src/cache-tags.js CHANGED
@@ -134,10 +134,14 @@ export async function addKeyToTags(tags, cacheKey, ttlMs) {
134
134
  * }
135
135
  * ```
136
136
  *
137
+ * Next.js 16's second argument (a cache-life profile, `'max'` or
138
+ * `{ expire }`) is accepted and ignored: eviction is immediate (#1686).
139
+ *
137
140
  * @param {string} tag
141
+ * @param {string | { expire?: number }} [_profile]
138
142
  * @returns {Promise<void>}
139
143
  */
140
- export async function revalidateTag(tag) {
144
+ export async function revalidateTag(tag, _profile) {
141
145
  if (typeof tag !== 'string' || !tag) return;
142
146
  const store = getStore();
143
147
  const keys = await readTagKeys(tag);
@@ -179,6 +179,91 @@ const HTMLELEMENT_MEMBERS = [
179
179
  'focus', 'blur', 'scrollIntoView',
180
180
  ];
181
181
 
182
+ /**
183
+ * Writes to a signal field of a WebComponent class body: any `this.<name>`
184
+ * assignment (plain, compound, `++` / `--`) where `<name>` is a field the same
185
+ * class creates with `signal(` / `computed(`, either as a class-field
186
+ * initializer (`readonly #open = signal(false)`) or in the constructor
187
+ * (`this.open = signal(false)`). That creation is the one write allowed.
188
+ * Assigning to the field replaces the signal object: the component stays
189
+ * subscribed to the old one, so nothing re-renders, and the next `.get()`
190
+ * throws. Expects the redacted body (strings and template text blanked,
191
+ * holes kept).
192
+ *
193
+ * @param {string} body
194
+ * @returns {string[]} the offending field names, each once, `#` kept
195
+ */
196
+ export function findSignalFieldWrites(body) {
197
+ const MAKE = String.raw`(?:signal|computed)\s*(?:<[^()]*>)?\s*\(`;
198
+ // Brace depth at each index, so a field initializer counts only at the
199
+ // class body's own level.
200
+ const depth = new Int32Array(body.length + 1);
201
+ for (let i = 0, d = 0; i < body.length; i++) {
202
+ if (body[i] === '{') d++;
203
+ else if (body[i] === '}') d--;
204
+ depth[i + 1] = d;
205
+ }
206
+ const fields = new Set();
207
+ // Find each class-level `= signal(` / `= computed(`, then read the field
208
+ // name from the start of its member, so a type annotation of any shape
209
+ // (`Signal<{ open: boolean }>`, `Signal<() => void>`, one spanning lines)
210
+ // sits in between.
211
+ const make = new RegExp(String.raw`(?<![=!<>])=(?!=)\s*` + MAKE, 'g');
212
+ const head = /^\s*((?:(?:public|private|protected|readonly|static|declare|override)\s+)*)(#?[A-Za-z_$][\w$]*)/;
213
+ let m;
214
+ while ((m = make.exec(body))) {
215
+ if (depth[m.index] !== 0) continue;
216
+ const h = head.exec(body.slice(memberStart(body, m.index), m.index));
217
+ if (h && !/\bstatic\s/.test(h[1])) fields.add(h[2]);
218
+ }
219
+ const ctor = methodBodyOf(body, 'constructor');
220
+ const made = new RegExp(String.raw`this\.(#?[A-Za-z_$][\w$]*)\s*=\s*` + MAKE, 'g');
221
+ const created = new Set();
222
+ while ((m = made.exec(ctor))) { fields.add(m[1]); created.add(m.index); }
223
+ if (!fields.size) return [];
224
+ const bad = new Set();
225
+ const write = /this\.(#?[A-Za-z_$][\w$]*)\s*(?:(\+\+|--)|(?:[-+*\/%]|\*\*|\|\||&&|\?\?|<<|>>>?|[&|^])?=(?![=>]))|(?:\+\+|--)\s*this\.(#?[A-Za-z_$][\w$]*)/g;
226
+ const ctorStart = ctor ? body.indexOf(ctor) : -1;
227
+ while ((m = write.exec(body))) {
228
+ const name = m[1] || m[3];
229
+ if (!fields.has(name)) continue;
230
+ // The creation in the constructor is the one allowed write.
231
+ if (ctorStart !== -1 && m.index >= ctorStart && m.index < ctorStart + ctor.length && created.has(m.index - ctorStart)) continue;
232
+ bad.add(name);
233
+ }
234
+ return [...bad];
235
+ }
236
+
237
+ /**
238
+ * Index where the class member holding `end` starts: just past the previous
239
+ * `;`, line break, or method body, skipping any of those nested inside
240
+ * brackets (a type annotation can hold all three).
241
+ * @param {string} body
242
+ * @param {number} end
243
+ * @returns {number}
244
+ */
245
+ function memberStart(body, end) {
246
+ let nest = 0;
247
+ for (let i = end - 1; i >= 0; i--) {
248
+ const c = body[i];
249
+ if (c === '>' && body[i - 1] === '=') { i--; continue; }
250
+ if (c === ')' || c === ']' || c === '}' || c === '>') {
251
+ // A method body ends the previous member: `m() { ... } x = signal(0)`.
252
+ if (c === '}' && nest === 0) {
253
+ let j = i - 1;
254
+ for (let d = 1; j >= 0 && d; j--) d += body[j] === '}' ? 1 : body[j] === '{' ? -1 : 0;
255
+ if (/\)\s*$/.test(body.slice(0, j + 1))) return i + 1;
256
+ }
257
+ nest++;
258
+ } else if (c === '(' || c === '[' || c === '{' || c === '<') {
259
+ nest--;
260
+ } else if (nest === 0 && (c === ';' || c === '\n')) {
261
+ return i + 1;
262
+ }
263
+ }
264
+ return 0;
265
+ }
266
+
182
267
  /**
183
268
  * Extract the body text of a named method from a (redacted) class body, or
184
269
  * '' if absent. Handles `async`, a TS return-type annotation, and params.
@@ -38,12 +38,18 @@ export function checkUseServerNeedsExtension(files, violations) {
38
38
  if (/\.server\.m?[jt]s$/.test(rel)) continue; // OK: has both markers
39
39
  const fileBase = basename(rel);
40
40
  const renamedBase = fileBase.replace(/\.(m?[jt]sx?)$/, '.server.$1');
41
+ // A routing file (page, layout, ...) cannot be renamed: the router
42
+ // matches its exact name, so `page.server.ts` would stop being a page.
43
+ // Next.js lets a page carry 'use server' actions; here they move out.
44
+ const routing = /^(?:page|layout|route|error|loading|not-found|forbidden|unauthorized|global-error|global-not-found|middleware|template|default)\.m?[jt]sx?$/.test(fileBase);
41
45
  violations.push({
42
46
  rule: 'use-server-needs-extension',
43
47
  file: rel,
44
48
  message:
45
49
  "File declares `'use server'` but its name does not match `.server.{js,ts,mts,mjs}`. The directive is silently ignored: the file is served to the browser as plain source and its exports are not RPC-callable. Code the developer expects to run on the server actually runs in the browser.",
46
- fix: `Rename to ${renamedBase} (add the .server. infix before the extension)`,
50
+ fix: routing
51
+ ? `Do not rename ${fileBase}: it is a routing file. Move its server actions into their own module (modules/<feature>/actions/<name>.server.ts starting with 'use server'), export them, and import them here.`
52
+ : `Rename to ${renamedBase} (add the .server. infix before the extension)`,
47
53
  });
48
54
  }
49
55
  }
@@ -172,3 +178,33 @@ export function checkOneActionPerConfiguredFile(files, violations) {
172
178
  }
173
179
  }
174
180
  }
181
+
182
+ /**
183
+ * Rule: `no-inline-use-server`.
184
+ *
185
+ * A `'use server'` directive as the first statement of a FUNCTION body,
186
+ * Next.js's inline server action. WebJs honours the directive only at the top
187
+ * of a `*.server.{js,ts}` module, so an inline one is an ordinary string: the
188
+ * function is not an action, runs wherever it is called, and binding it to a
189
+ * form throws because it has no action identity.
190
+ *
191
+ * @param {ScannedFile[]} files
192
+ * @param {Violation[]} violations
193
+ */
194
+ export function checkNoInlineUseServer(files, violations) {
195
+ for (const { rel, scan } of files) {
196
+ // `scan` blanks comments and template bodies but keeps plain-string
197
+ // bodies, so the directive is visible and a mention in a comment or a
198
+ // template sample is not.
199
+ const re = /\{\s*(['"])use server\1/g;
200
+ const m = re.exec(scan);
201
+ if (!m) continue;
202
+ const line = scan.slice(0, m.index).split('\n').length;
203
+ violations.push({
204
+ rule: 'no-inline-use-server',
205
+ file: rel,
206
+ message: `A 'use server' directive inside a function body (line ${line}) does nothing in WebJs: the server boundary is a FILE, so this function is not a server action and runs wherever it is called. Binding it to a <form action> throws.`,
207
+ fix: "Move the function into its own module, e.g. modules/<feature>/actions/<name>.server.ts whose first line is 'use server', export it, and import it where it is used (a form binds it with action=${fn}).",
208
+ });
209
+ }
210
+ }
@@ -7,11 +7,11 @@
7
7
  * logic, and its position in the run order.
8
8
  */
9
9
  import {
10
- extractWebComponentClassBodies, matchClosingBrace, parsePropEntries,
10
+ extractWebComponentClassBodies, matchClosingBrace, parsePropEntries, parsePropsFromObjectLiteral,
11
11
  } from '../js-scan.js';
12
12
  import {
13
- isComponentFile, arrayPropUsesObject, findFieldInitializers, methodBodyOf,
14
- findBrowserMemberUses,
13
+ isComponentFile, arrayPropUsesObject, findFieldInitializers,
14
+ findSignalFieldWrites, methodBodyOf, findBrowserMemberUses,
15
15
  } from './helpers.js';
16
16
 
17
17
  /**
@@ -54,35 +54,31 @@ export function checkComponentsHaveRegister(files, violations) {
54
54
  }
55
55
 
56
56
  /**
57
- * Rule: `no-static-properties`.
57
+ * Rule: `no-signal-field-assignment`.
58
58
  *
59
59
  * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
60
60
  * @param {Violation[]} violations accumulator the rule pushes onto
61
61
  * @returns {void}
62
62
  */
63
- export function checkNoStaticProperties(files, violations) {
64
- // --- Rule: no-static-properties ---
65
- // A hand-written `static properties = { … }` in a WebComponent class body is
66
- // no longer supported: reactive properties are declared via the
67
- // `extends WebComponent({ … })` factory (the runtime throws on a direct
68
- // `static properties`). Flag it statically so the editor catches it before
69
- // the page 500s.
63
+ export function checkNoSignalFieldAssignment(files, violations) {
64
+ // --- Rule: no-signal-field-assignment ---
65
+ // A signal field is written through `.set()`, never by assignment, which
66
+ // swaps the signal object out from under the component (#1679). TypeScript
67
+ // `readonly` catches this where tsc runs; this rule covers plain `.js` and a
68
+ // forgotten `readonly`. It only sees writes inside the declaring class,
69
+ // which for a `#private` field is every write there can be.
70
70
  {
71
71
  for (const { rel, scan } of files) {
72
- // Use redacted source so fixture-style strings like
73
- // `class X extends WebComponent { static properties = {…} }` inside
74
- // template literals don't trip the rule. Real declarations live at
75
- // top-level code where the redactor leaves them alone.
76
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
72
+ if (!/class\s+\w+(?:\s*<[^{}();]*?>)?\s+extends\s+WebComponent/.test(scan)) continue;
77
73
  for (const { body } of extractWebComponentClassBodies(scan)) {
78
- if (!/static\s+properties\s*=\s*\{/.test(body)) continue;
79
- violations.push({
80
- rule: 'no-static-properties',
81
- file: rel,
82
- message:
83
- '`static properties = { … }` is no longer supported; declare reactive properties via the `extends WebComponent({ … })` factory instead.',
84
- fix: 'Move the properties into the factory call: `class X extends WebComponent({ count: Number })`. Use `prop(Number, { reflect: true })` for options and set defaults in the constructor after super(). Delete the `static properties` block and any `declare` fields for those props.',
85
- });
74
+ for (const name of findSignalFieldWrites(body)) {
75
+ violations.push({
76
+ rule: 'no-signal-field-assignment',
77
+ file: rel,
78
+ message: `\`this.${name}\` holds a signal and is assigned to. Assignment replaces the signal object: the component stays subscribed to the old one, nothing re-renders, and the next \`.get()\` throws.`,
79
+ fix: `Write through the signal: \`this.${name}.set(value)\` (or \`this.${name}.set(this.${name}.get() + 1)\` for a counter), and declare the field \`readonly\` in TypeScript so the compiler catches the next one.`,
80
+ });
81
+ }
86
82
  }
87
83
  }
88
84
  }
@@ -102,13 +98,17 @@ export function checkReactivePropsNoClassField(files, violations) {
102
98
  // semantics (including `erasableSyntaxOnly: true`) every class-field declaration
103
99
  // compiles to Object.defineProperty after super() and clobbers the framework's
104
100
  // reactive accessor (silent broken re-renders). Catches `count = 0`,
105
- // `count: number = 0`, `count!: number`, and `count?: number`.
101
+ // `count: number = 0`, `count!: number`, and `count?: number`. Props declared
102
+ // in Lit's `static properties` count the same as factory props, since the
103
+ // field clobbers the accessor either way; `declare count: number` is erased
104
+ // and is never flagged.
106
105
  {
107
106
  for (const { rel, scan } of files) {
108
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
107
+ if (!/class\s+\w+(?:\s*<[^{}();]*?>)?\s+extends\s+WebComponent/.test(scan)) continue;
109
108
  for (const { body, factoryProps } of extractWebComponentClassBodies(scan)) {
110
- if (factoryProps.size === 0) continue;
111
- for (const bad of findFieldInitializers(body, factoryProps)) {
109
+ const props = new Set([...factoryProps, ...staticPropertyNames(body)]);
110
+ if (props.size === 0) continue;
111
+ for (const bad of findFieldInitializers(body, props)) {
112
112
  violations.push({
113
113
  rule: 'reactive-props-no-class-field',
114
114
  file: rel,
@@ -121,6 +121,32 @@ export function checkReactivePropsNoClassField(files, violations) {
121
121
  }
122
122
  }
123
123
 
124
+ /**
125
+ * The prop names a class body declares through Lit's `static properties`,
126
+ * spelled as a field (with any of `override` / `readonly` / an access
127
+ * modifier, and an optional type annotation) or as a `static get` returning an
128
+ * object literal. Reads the redacted body, so a quoted key is not recovered;
129
+ * that only means one fewer name to check, never a false positive.
130
+ *
131
+ * @param {string} body a class body from `extractWebComponentClassBodies`
132
+ * @returns {Set<string>}
133
+ */
134
+ function staticPropertyNames(body) {
135
+ const names = new Set();
136
+ const mods = '(?:(?:override|readonly|public|protected)\\s+)*';
137
+ const field = new RegExp(`\\bstatic\\s+${mods}properties\\s*(?::[^=;{]*)?=\\s*\\{`, 'g');
138
+ const getter = new RegExp(`\\bstatic\\s+${mods}get\\s+properties\\s*\\(\\s*\\)[^{]*\\{\\s*return\\s*\\{`, 'g');
139
+ for (const re of [field, getter]) {
140
+ let m;
141
+ while ((m = re.exec(body)) !== null) {
142
+ const start = m.index + m[0].length;
143
+ const end = matchClosingBrace(body, start);
144
+ if (end !== -1) parsePropsFromObjectLiteral(body.slice(start, end), names);
145
+ }
146
+ }
147
+ return names;
148
+ }
149
+
124
150
  /**
125
151
  * Rule: `array-prop-uses-array-type`.
126
152
  *
@@ -273,3 +299,82 @@ export function checkNoShadowedNativeMember(files, violations) {
273
299
  }
274
300
  }
275
301
  }
302
+
303
+ /**
304
+ * The value a class body gives `static shadow`: `true`, `false` (an explicit
305
+ * `= false` or no declaration at all, the light-DOM default), or `null` when
306
+ * it cannot be evaluated statically (a getter, a variable, an expression).
307
+ * @param {string} body string/comment-masked class body
308
+ * @returns {boolean | null}
309
+ */
310
+ function staticShadowValue(body) {
311
+ if (/\bstatic\s+(?:override\s+)?get\s+shadow\b/.test(body)) return null;
312
+ const m = /\bstatic\s+(?:override\s+)?(?:readonly\s+)?shadow\s*(?::[^=;\n]*)?=\s*([^;\n]*)/.exec(body);
313
+ if (!m) return false;
314
+ const v = m[1].trim();
315
+ if (/^true\b/.test(v)) return true;
316
+ if (/^false\b/.test(v)) return false;
317
+ return null;
318
+ }
319
+
320
+ /**
321
+ * Rules: `static-styles-need-shadow` and `shadow-options-need-shadow`.
322
+ *
323
+ * WebJs adopts `static styles` into a SHADOW root, and `static
324
+ * shadowRootOptions` configures that root, but a component renders into light
325
+ * DOM unless it sets `static shadow = true`. Lit's habit is the opposite
326
+ * (shadow by default), so a Lit-shaped component declaring either one with no
327
+ * `static shadow = true` ships styles that never apply, or options nothing
328
+ * reads, with no other signal at edit time.
329
+ *
330
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
331
+ * @param {Violation[]} violations accumulator the rule pushes onto
332
+ * @returns {void}
333
+ */
334
+ export function checkShadowOnlyStatics(files, violations) {
335
+ for (const { rel, scan } of files) {
336
+ if (!/class\s+\w+[^{]*?\s+extends\s+WebComponent/.test(scan)) continue;
337
+ for (const { body } of extractWebComponentClassBodies(scan)) {
338
+ if (staticShadowValue(body) !== false) continue;
339
+ // Only TOP-LEVEL members of this class, never a nested class's.
340
+ const top = topLevelText(body);
341
+ if (/\bstatic\s+(?:override\s+)?(?:readonly\s+)?(?:get\s+)?styles\b/.test(top)) {
342
+ violations.push({
343
+ rule: 'static-styles-need-shadow',
344
+ file: rel,
345
+ message:
346
+ '`static styles` on a light-DOM component is never applied. WebJs adopts it into a shadow root, and this component renders into light DOM (the default, unlike Lit) because it does not set `static shadow = true`.',
347
+ fix:
348
+ 'Add `static shadow = true` to scope the styles in a shadow root, or drop `static styles` and style the light DOM with Tailwind utilities or a global stylesheet whose class selectors are prefixed with the tag name.',
349
+ });
350
+ }
351
+ if (/\bstatic\s+(?:override\s+)?(?:readonly\s+)?(?:get\s+)?shadowRootOptions\b/.test(top)) {
352
+ violations.push({
353
+ rule: 'shadow-options-need-shadow',
354
+ file: rel,
355
+ message:
356
+ '`static shadowRootOptions` configures a shadow root, and this component has none: it renders into light DOM (the default, unlike Lit) because it does not set `static shadow = true`, so the options are ignored.',
357
+ fix: 'Add `static shadow = true` so the component attaches a shadow root with these options, or remove `shadowRootOptions`.',
358
+ });
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ /**
365
+ * The text of a class body at brace depth 0, with every nested block blanked,
366
+ * so a member of a nested class or object literal never reads as this class's.
367
+ * @param {string} body
368
+ * @returns {string}
369
+ */
370
+ function topLevelText(body) {
371
+ let d = 0;
372
+ let out = '';
373
+ for (let i = 0; i < body.length; i++) {
374
+ const c = body[i];
375
+ if (c === '{') { d++; out += ' '; continue; }
376
+ if (c === '}') { d--; out += ' '; continue; }
377
+ out += d === 0 ? c : (c === '\n' ? '\n' : ' ');
378
+ }
379
+ return out;
380
+ }
@@ -6,11 +6,15 @@
6
6
  * in one 900-line function. Each rule keeps its original comment header, its
7
7
  * logic, and its position in the run order.
8
8
  */
9
- import { relative } from 'node:path';
9
+ import { relative, dirname, sep } from 'node:path';
10
10
  import {
11
11
  redactStringsAndTemplates, redactToPlaceholders, classifyActionHole,
12
12
  } from '../js-scan.js';
13
13
  import { resolveImport } from '../module-graph.js';
14
+ import { coreSurface, isTypeOnlyAnywhere } from '../core-exports.js';
15
+ import { missingCoreNameFix } from '../lit-hints.js';
16
+ import { foreignImport, foreignImportAllowed } from '../foreign-imports.js';
17
+ import { CSS_IMPORT_FIX } from '../app-layout.js';
14
18
  import {
15
19
  hasUseServerDirective, enumerableExports, importedLocalNames, importedValueNames,
16
20
  } from './helpers.js';
@@ -31,7 +35,7 @@ import {
31
35
  export function checkNoMissingLocalImport(appDir, files, violations) {
32
36
  // --- Rule: no-missing-local-import ---
33
37
  // A named value import of a symbol an app-internal module does not export is a
34
- // runtime crash (the binding is undefined) that the elision-based checks miss,
38
+ // runtime crash (the binding is undefined) that the graph-based checks miss,
35
39
  // so `check` can stay green while `typecheck` is red (a dropped schema table
36
40
  // orphaning a gallery module is the motivating dogfood case). Conservative by
37
41
  // construction: only app-internal specifiers resolving to a known app file,
@@ -71,11 +75,21 @@ export function checkNoMissingLocalImport(appDir, files, violations) {
71
75
  if (exp == null) continue; // not an app file, or exports not enumerable
72
76
  for (const name of names) {
73
77
  if (exp.has(name)) continue;
78
+ // The usual cause is the right name from the wrong module (a UI
79
+ // helper imported from its sibling's file), so name the app modules
80
+ // that do export it, spelled the way this import was (#1686).
81
+ const owners = [];
82
+ for (const f of files) {
83
+ if (f.abs !== target && exportsByAbs.get(f.abs)?.has(name)) owners.push(specFor(f.abs, spec, abs, appDir));
84
+ }
85
+ const elsewhere = owners.length ? ` \`${name}\` is exported by ${owners.slice(0, 3).map((o) => `\`${o}\``).join(', ')}.` : '';
74
86
  violations.push({
75
87
  rule: 'no-missing-local-import',
76
88
  file: rel,
77
- message: `Imports \`${name}\` from \`${spec}\`, but that module does not export \`${name}\`. The binding is undefined at runtime and crashes on first use (often a renamed or removed export, e.g. a dropped schema table).`,
78
- fix: `Add \`${name}\` to \`${spec}\`, correct the imported name, or remove the import.`,
89
+ message: `Imports \`${name}\` from \`${spec}\`, but that module does not export \`${name}\`. The binding is undefined at runtime and crashes on first use (often a renamed or removed export, e.g. a dropped schema table).${elsewhere}`,
90
+ fix: owners.length
91
+ ? `Import \`${name}\` from \`${owners[0]}\`.`
92
+ : `Add \`${name}\` to \`${spec}\`, correct the imported name, or remove the import.`,
79
93
  });
80
94
  }
81
95
  }
@@ -164,3 +178,160 @@ export function checkFormActionNotAGetAction(appDir, files, violations) {
164
178
  }
165
179
  }
166
180
  }
181
+
182
+ /**
183
+ * Rule: `core-imports-exist`.
184
+ *
185
+ * A named import from `@webjsdev/core` (or one of its subpaths) of a name the
186
+ * module does not export is a load-time crash: ESM refuses to link the module
187
+ * with `does not provide an export named X`, which names neither the cause nor
188
+ * the fix. Lit muscle memory produces exactly this (`LitElement`, a decorator
189
+ * function, `PropertyValues` imported as a value). The rule reads the
190
+ * INSTALLED core's real export surface (`core-exports.js`), so a name core
191
+ * gains later is accepted the day it ships, and maps the miss to its WebJs fix
192
+ * through the `lit-hints.js` table: a type-only name gets the `import type`
193
+ * fix, a Lit name that will never exist gets its WebJs spelling.
194
+ *
195
+ * @param {string} appDir absolute path to the app root
196
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
197
+ * @param {Violation[]} violations accumulator the rule pushes onto
198
+ * @returns {void}
199
+ */
200
+ export function checkCoreImportsExist(appDir, files, violations) {
201
+ /** @type {import('../core-exports.js').CoreSurface | null | undefined} */
202
+ let surface;
203
+ // Statement position only (line start or after `;` / `}`), so an import
204
+ // spelled inside a plain string, which the mask keeps verbatim, is not read.
205
+ const IMPORT_RE = /(?:^|[;}])[ \t]*(import|export)\s+(type\s+)?([^'";]*?)\s*from\s*(['"])(@webjsdev\/core(?:\/[^'"]*)?)\4/gm;
206
+ for (const f of files) {
207
+ if (!f.content.includes('@webjsdev/core')) continue;
208
+ // The default mask keeps plain-string bodies (the specifier) and blanks
209
+ // comments and templates, so an import shown in a docs code sample or a
210
+ // JSDoc example is never read as a real one.
211
+ IMPORT_RE.lastIndex = 0;
212
+ let m;
213
+ while ((m = IMPORT_RE.exec(f.scan))) {
214
+ const [, kw, typeKw, clauseRaw, , spec] = m;
215
+ if (typeKw) continue; // `import type { ... }` is erased before load
216
+ if (surface === undefined) surface = coreSurface(appDir);
217
+ if (!surface) return;
218
+ const line = f.content.slice(0, m.index).split('\n').length;
219
+ const mod = surface.subpaths.get(spec);
220
+ if (!mod) {
221
+ if (spec === '@webjsdev/core/package.json') continue;
222
+ violations.push({
223
+ rule: 'core-imports-exist',
224
+ file: f.rel,
225
+ message: `\`${spec}\` (line ${line}) is not a subpath @webjsdev/core exports, so the import fails to resolve at load.`,
226
+ fix: `Import from one of: ${[...surface.subpaths.keys()].map((k) => '`' + k + '`').join(', ')}.`,
227
+ });
228
+ continue;
229
+ }
230
+ const clause = clauseRaw.trim();
231
+ if (kw === 'export' && clause.startsWith('*')) continue;
232
+ // Default import (`import x from` or `import x, { ... } from`).
233
+ const defMatch = kw === 'import' ? /^([A-Za-z_$][\w$]*)\s*(?:,|$)/.exec(clause) : null;
234
+ if (defMatch && !mod.hasDefault) {
235
+ violations.push({
236
+ rule: 'core-imports-exist',
237
+ file: f.rel,
238
+ message: `\`import ${defMatch[1]} from '${spec}'\` (line ${line}) is a default import, and ${spec} has no default export, so the module fails to load.`,
239
+ fix: `Use named imports: \`import { WebComponent, html } from '${spec}'\`, or a namespace import \`import * as ${defMatch[1]} from '${spec}'\`.`,
240
+ });
241
+ }
242
+ const brace = /\{([^}]*)\}/.exec(clause);
243
+ if (!brace) continue;
244
+ for (const part of brace[1].split(',')) {
245
+ const p = part.trim();
246
+ if (!p || /^type\s/.test(p)) continue;
247
+ const name = p.split(/\s+as\s+/)[0].trim();
248
+ if (!/^[A-Za-z_$][\w$]*$/.test(name) || mod.runtime.has(name)) continue;
249
+ const typeOnly = mod.types.has(name) || isTypeOnlyAnywhere(surface, name);
250
+ const elsewhere = typeOnly ? null : [...surface.subpaths].find(([, s]) => s.runtime.has(name));
251
+ violations.push({
252
+ rule: 'core-imports-exist',
253
+ file: f.rel,
254
+ message: typeOnly
255
+ ? `\`${name}\` (line ${line}) is imported as a value from ${spec}, which exports it only as a type. The type stripper keeps a plain import, so the module fails to load with "does not provide an export named ${name}".`
256
+ : `\`${name}\` (line ${line}) is not exported by ${spec}, so the module fails to load with "does not provide an export named ${name}".`,
257
+ fix: elsewhere
258
+ ? `Import it from \`${elsewhere[0]}\`.`
259
+ : missingCoreNameFix(name, { typeOnly, specifier: spec }),
260
+ });
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Rule: `no-foreign-framework-import`.
268
+ *
269
+ * A `next/*`, `react`, `react-dom`, `lit`, `lit-html`, `lit-element`,
270
+ * `@lit/*` or `@lit-labs/*` import, in any form (named, default, namespace,
271
+ * `import type`, side-effect, `export ... from`, or a string-literal dynamic
272
+ * `import()`). WebJs never loads those packages; the violation names the exact
273
+ * `@webjsdev/*` replacement from the shared table in foreign-imports.js.
274
+ * `react` / `react-dom` stay allowed in a `*.server.*` module.
275
+ *
276
+ * @param {ScannedFile[]} files
277
+ * @param {Violation[]} violations
278
+ * @returns {void}
279
+ */
280
+ export function checkNoForeignFrameworkImport(files, violations) {
281
+ // Keyword positions come from the fully-blanked view (no literal body
282
+ // survives, so an import written inside a string or template is never
283
+ // matched); the specifier is read back from the raw content at the same,
284
+ // length-preserved offset.
285
+ const reStatic = /\b(?:import|export)\b[^'";]*?\bfrom\s*(['"])/g;
286
+ const reBare = /\bimport\s*(['"])/g;
287
+ const reDynamic = /\bimport\s*\(\s*(['"])/g;
288
+ for (const { rel, content } of files) {
289
+ const masked = redactStringsAndTemplates(content, true);
290
+ const seen = new Set();
291
+ for (const re of [reStatic, reBare, reDynamic]) {
292
+ re.lastIndex = 0;
293
+ let m;
294
+ while ((m = re.exec(masked))) {
295
+ const quote = m[1];
296
+ const start = re.lastIndex;
297
+ const end = content.indexOf(quote, start);
298
+ if (end < 0) continue;
299
+ const spec = content.slice(start, end);
300
+ if (/\.css(?:\?.*)?$/i.test(spec) && !seen.has(spec)) {
301
+ seen.add(spec);
302
+ violations.push({
303
+ rule: 'no-css-import',
304
+ file: rel,
305
+ message: `Imports '${spec}', a stylesheet. WebJs has no bundler: the import fails on Node, is silently dropped on Bun, and stops the module from loading in the browser.`,
306
+ fix: CSS_IMPORT_FIX,
307
+ });
308
+ continue;
309
+ }
310
+ const f = foreignImport(spec);
311
+ if (!f || seen.has(spec) || foreignImportAllowed(spec, rel)) continue;
312
+ seen.add(spec);
313
+ violations.push({
314
+ rule: 'no-foreign-framework-import',
315
+ file: rel,
316
+ message: `Imports '${spec}', a ${f.framework} package. WebJs does not load ${f.framework}: the import fails in the browser and on the server, or (for Lit) loads a second component runtime beside WebJs.`,
317
+ fix: f.fix,
318
+ });
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ /**
325
+ * The specifier for an app module, in the style the failing import used: a
326
+ * `#` path from the app root for a `#` import, else a path relative to the
327
+ * importer.
328
+ * @param {string} targetAbs
329
+ * @param {string} styleSpec
330
+ * @param {string} importerAbs
331
+ * @param {string} appDir
332
+ */
333
+ function specFor(targetAbs, styleSpec, importerAbs, appDir) {
334
+ if (styleSpec.startsWith('#')) return '#' + relative(appDir, targetAbs).split(sep).join('/');
335
+ const r = relative(dirname(importerAbs), targetAbs).split(sep).join('/');
336
+ return r.startsWith('.') ? r : './' + r;
337
+ }