@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.
- package/README.md +4 -1
- package/index.d.ts +461 -102
- package/index.js +9 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-input.js +208 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +112 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/helpers.js +85 -0
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +133 -28
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +109 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +24 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/dev-status.js +122 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +632 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +191 -133
- package/src/dev/server.js +109 -37
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +322 -78
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev-styles.js +64 -6
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +150 -69
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +49 -17
- package/src/ssr/head.js +228 -25
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +243 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +52 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +85 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +3 -8
- package/src/component-elision.js +0 -1852
- package/src/elision-differential.js +0 -101
- package/src/elision-report.js +0 -193
package/src/browser-entries.js
CHANGED
|
@@ -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);
|
package/src/build-epoch.js
CHANGED
|
@@ -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,
|
|
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);
|
package/src/check/helpers.js
CHANGED
|
@@ -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:
|
|
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,
|
|
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-
|
|
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
|
|
64
|
-
// --- Rule: no-
|
|
65
|
-
// A
|
|
66
|
-
//
|
|
67
|
-
// `
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
107
|
+
if (!/class\s+\w+(?:\s*<[^{}();]*?>)?\s+extends\s+WebComponent/.test(scan)) continue;
|
|
109
108
|
for (const { body, factoryProps } of extractWebComponentClassBodies(scan)) {
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|