@webjsdev/server 0.9.0 → 0.10.1
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 +793 -125
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -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 +97 -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/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- 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 +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -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 +269 -0
- package/src/core-exports.js +190 -0
- package/src/csrf.js +21 -3
- 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/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -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 +177 -129
- package/src/dev/server.js +92 -33
- 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 +2 -2
- 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.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 +142 -66
- 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 +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +71 -2
- package/src/testing.js +103 -21
- 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 +1 -6
- package/src/component-elision.js +0 -1828
- 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);
|
|
@@ -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,7 +7,7 @@
|
|
|
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
13
|
isComponentFile, arrayPropUsesObject, findFieldInitializers,
|
|
@@ -53,41 +53,6 @@ export function checkComponentsHaveRegister(files, violations) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
57
|
-
* Rule: `no-static-properties`.
|
|
58
|
-
*
|
|
59
|
-
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
60
|
-
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
61
|
-
* @returns {void}
|
|
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.
|
|
70
|
-
{
|
|
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;
|
|
77
|
-
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
|
-
});
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
56
|
/**
|
|
92
57
|
* Rule: `no-signal-field-assignment`.
|
|
93
58
|
*
|
|
@@ -133,13 +98,17 @@ export function checkReactivePropsNoClassField(files, violations) {
|
|
|
133
98
|
// semantics (including `erasableSyntaxOnly: true`) every class-field declaration
|
|
134
99
|
// compiles to Object.defineProperty after super() and clobbers the framework's
|
|
135
100
|
// reactive accessor (silent broken re-renders). Catches `count = 0`,
|
|
136
|
-
// `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.
|
|
137
105
|
{
|
|
138
106
|
for (const { rel, scan } of files) {
|
|
139
|
-
if (!/class\s+\w
|
|
107
|
+
if (!/class\s+\w+(?:\s*<[^{}();]*?>)?\s+extends\s+WebComponent/.test(scan)) continue;
|
|
140
108
|
for (const { body, factoryProps } of extractWebComponentClassBodies(scan)) {
|
|
141
|
-
|
|
142
|
-
|
|
109
|
+
const props = new Set([...factoryProps, ...staticPropertyNames(body)]);
|
|
110
|
+
if (props.size === 0) continue;
|
|
111
|
+
for (const bad of findFieldInitializers(body, props)) {
|
|
143
112
|
violations.push({
|
|
144
113
|
rule: 'reactive-props-no-class-field',
|
|
145
114
|
file: rel,
|
|
@@ -152,6 +121,32 @@ export function checkReactivePropsNoClassField(files, violations) {
|
|
|
152
121
|
}
|
|
153
122
|
}
|
|
154
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
|
+
|
|
155
150
|
/**
|
|
156
151
|
* Rule: `array-prop-uses-array-type`.
|
|
157
152
|
*
|
|
@@ -304,3 +299,82 @@ export function checkNoShadowedNativeMember(files, violations) {
|
|
|
304
299
|
}
|
|
305
300
|
}
|
|
306
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
|
+
}
|