@webjsdev/server 0.8.63 → 0.8.65
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/index.d.ts +7 -2
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +173 -26
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- package/src/vendor.js +29 -2371
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component-authoring rules: registration, reactive-property declaration, and
|
|
3
|
+
* the SSR-safety of a component body.
|
|
4
|
+
*
|
|
5
|
+
* Lifted verbatim out of checkConventions, which held all twenty rules inline
|
|
6
|
+
* in one 900-line function. Each rule keeps its original comment header, its
|
|
7
|
+
* logic, and its position in the run order.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
extractWebComponentClassBodies, matchClosingBrace, parsePropEntries,
|
|
11
|
+
} from '../js-scan.js';
|
|
12
|
+
import {
|
|
13
|
+
isComponentFile, arrayPropUsesObject, findFieldInitializers, methodBodyOf,
|
|
14
|
+
findBrowserMemberUses,
|
|
15
|
+
} from './helpers.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {import('./rules.js').Violation} Violation
|
|
19
|
+
* @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rule: `components-have-register`.
|
|
25
|
+
*
|
|
26
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
27
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
28
|
+
* @returns {void}
|
|
29
|
+
*/
|
|
30
|
+
export function checkComponentsHaveRegister(files, violations) {
|
|
31
|
+
// --- Rule: components-have-register ---
|
|
32
|
+
{
|
|
33
|
+
for (const { rel, scan } of files) {
|
|
34
|
+
if (!isComponentFile(rel)) continue;
|
|
35
|
+
// Use redacted source so a code-example string like
|
|
36
|
+
// `Foo.register('bar')` inside a tagged template literal does
|
|
37
|
+
// not falsely satisfy the rule for a sibling unregistered
|
|
38
|
+
// class. Real register() calls live at top level where the
|
|
39
|
+
// redactor leaves them alone.
|
|
40
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
41
|
+
// Accept either registration pattern:
|
|
42
|
+
// Counter.register('tag') (WebJs idiom)
|
|
43
|
+
// customElements.define('tag', Counter) (native)
|
|
44
|
+
if (/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*['"`]/.test(scan)) continue;
|
|
45
|
+
if (/\bcustomElements\.define\s*\(/.test(scan)) continue;
|
|
46
|
+
violations.push({
|
|
47
|
+
rule: 'components-have-register',
|
|
48
|
+
file: rel,
|
|
49
|
+
message: "Component extends WebComponent but is never registered. Call ClassName.register('tag-name') at the bottom of the file.",
|
|
50
|
+
fix: "Add `ClassName.register('tag-name')` after the class definition",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
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
|
+
/**
|
|
92
|
+
* Rule: `reactive-props-no-class-field`.
|
|
93
|
+
*
|
|
94
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
95
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
96
|
+
* @returns {void}
|
|
97
|
+
*/
|
|
98
|
+
export function checkReactivePropsNoClassField(files, violations) {
|
|
99
|
+
// --- Rule: reactive-props-no-class-field ---
|
|
100
|
+
// A reactive property declared via the factory must not also carry a plain
|
|
101
|
+
// class-field declaration (initializer OR type-only): under modern class-field
|
|
102
|
+
// semantics (including `erasableSyntaxOnly: true`) every class-field declaration
|
|
103
|
+
// compiles to Object.defineProperty after super() and clobbers the framework's
|
|
104
|
+
// reactive accessor (silent broken re-renders). Catches `count = 0`,
|
|
105
|
+
// `count: number = 0`, `count!: number`, and `count?: number`.
|
|
106
|
+
{
|
|
107
|
+
for (const { rel, scan } of files) {
|
|
108
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
109
|
+
for (const { body, factoryProps } of extractWebComponentClassBodies(scan)) {
|
|
110
|
+
if (factoryProps.size === 0) continue;
|
|
111
|
+
for (const bad of findFieldInitializers(body, factoryProps)) {
|
|
112
|
+
violations.push({
|
|
113
|
+
rule: 'reactive-props-no-class-field',
|
|
114
|
+
file: rel,
|
|
115
|
+
message: `Reactive prop \`${bad}\` uses a class-field declaration (initializer or type-only); this clobbers the framework's reactive accessor under modern class-field semantics.`,
|
|
116
|
+
fix: `Delete the class-field declaration and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Rule: `array-prop-uses-array-type`.
|
|
126
|
+
*
|
|
127
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
128
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
129
|
+
* @returns {void}
|
|
130
|
+
*/
|
|
131
|
+
export function checkArrayPropUsesArrayType(files, violations) {
|
|
132
|
+
// --- Rule: array-prop-uses-array-type ---
|
|
133
|
+
// An array-typed reactive prop declared via the factory should pass the
|
|
134
|
+
// `Array` runtime constructor, not `Object`. Both share one converter
|
|
135
|
+
// (JSON encode/decode), so `Object` does not crash, but it misstates the
|
|
136
|
+
// prop contract and diverges from the documented built-in set. Fires only
|
|
137
|
+
// when the factory generic is itself an array type AND the constructor is
|
|
138
|
+
// `Object`; a bare `foo: Object` (no generic to prove array-ness) is left
|
|
139
|
+
// alone to avoid false positives. Uses the redacted `scan`, so a
|
|
140
|
+
// `prop<X[]>(Object)` shown inside an html`` example string never fires.
|
|
141
|
+
{
|
|
142
|
+
for (const { rel, scan } of files) {
|
|
143
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
144
|
+
for (const { factoryArg } of extractWebComponentClassBodies(scan)) {
|
|
145
|
+
const objStart = factoryArg.indexOf('{');
|
|
146
|
+
if (objStart === -1) continue;
|
|
147
|
+
const objEnd = matchClosingBrace(factoryArg, objStart + 1);
|
|
148
|
+
if (objEnd === -1) continue;
|
|
149
|
+
const objContent = factoryArg.slice(objStart + 1, objEnd);
|
|
150
|
+
for (const { key, value } of parsePropEntries(objContent)) {
|
|
151
|
+
if (!arrayPropUsesObject(value)) continue;
|
|
152
|
+
violations.push({
|
|
153
|
+
rule: 'array-prop-uses-array-type',
|
|
154
|
+
file: rel,
|
|
155
|
+
message: `Array-typed reactive prop \`${key}\` is declared with the \`Object\` constructor (\`prop<…[]>(Object)\`); use \`Array\` so the runtime converter matches the declared shape.`,
|
|
156
|
+
fix: `Change the constructor to \`Array\`: \`${key}: prop<…[]>(Array)\`. Object and Array share one converter so behaviour is unchanged, but \`Array\` states the prop's shape correctly.`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Rule: `no-browser-globals-in-render`.
|
|
166
|
+
*
|
|
167
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
168
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
169
|
+
* @returns {void}
|
|
170
|
+
*/
|
|
171
|
+
export function checkNoBrowserGlobalsInRender(files, violations) {
|
|
172
|
+
// --- Rule: no-browser-globals-in-render ---
|
|
173
|
+
// The SSR pipeline runs the constructor (`new Cls()`), willUpdate, and
|
|
174
|
+
// render() on the server element shim (attribute methods backed, but no real
|
|
175
|
+
// DOM). A genuinely browser-only global or an unshimmed HTMLElement member on
|
|
176
|
+
// `this` touched in any of those throws at SSR time. Those belong in
|
|
177
|
+
// connectedCallback / post-render hooks, which SSR never calls. willUpdate is
|
|
178
|
+
// scanned because it now runs at SSR (issue #217).
|
|
179
|
+
{
|
|
180
|
+
for (const { rel, scan } of files) {
|
|
181
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
182
|
+
for (const { body } of extractWebComponentClassBodies(scan)) {
|
|
183
|
+
for (const method of ['constructor', 'willUpdate', 'render']) {
|
|
184
|
+
const code = methodBodyOf(body, method);
|
|
185
|
+
if (!code) continue;
|
|
186
|
+
for (const { member, kind } of findBrowserMemberUses(code)) {
|
|
187
|
+
violations.push({
|
|
188
|
+
rule: 'no-browser-globals-in-render',
|
|
189
|
+
file: rel,
|
|
190
|
+
message: `\`${member}\` (${kind}) is used in ${method}(), which runs during SSR where it is not available, so it throws and the component fails to server-render.`,
|
|
191
|
+
fix: `Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls. Seed first-paint defaults in the constructor only from server-known inputs (attributes / props), then refine in connectedCallback by writing to a signal.`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Rule: `no-shadowed-native-member`.
|
|
202
|
+
*
|
|
203
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
204
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
205
|
+
* @returns {void}
|
|
206
|
+
*/
|
|
207
|
+
export function checkNoShadowedNativeMember(files, violations) {
|
|
208
|
+
// --- Rule: no-shadowed-native-member ---
|
|
209
|
+
// A WebComponent method named after a native DOM mutation method WebJs
|
|
210
|
+
// instruments on every light-DOM host for the slot API (#1021) is SHADOWED at
|
|
211
|
+
// runtime (the native/instrumented method wins), so the component method never
|
|
212
|
+
// runs, while TypeScript stays green (a shorter override is assignable). Rename
|
|
213
|
+
// the handler. The lifecycle hooks are meant to be overridden, so only the DOM
|
|
214
|
+
// mutation members are reserved.
|
|
215
|
+
{
|
|
216
|
+
const NATIVE_MEMBERS = [
|
|
217
|
+
'append', 'prepend', 'before', 'after', 'replaceWith', 'replaceChildren', 'remove',
|
|
218
|
+
'appendChild', 'insertBefore', 'removeChild', 'replaceChild',
|
|
219
|
+
];
|
|
220
|
+
for (const { rel, scan } of files) {
|
|
221
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
222
|
+
for (const { body } of extractWebComponentClassBodies(scan)) {
|
|
223
|
+
// The slot interception installs only on LIGHT-DOM hosts (the shadow
|
|
224
|
+
// branch keeps the true native methods), so a shadow component's method
|
|
225
|
+
// is never shadowed. Skip the whole class.
|
|
226
|
+
if (/static\s+shadow\s*=\s*true/.test(body)) continue;
|
|
227
|
+
// Brace-depth map over the (already string/comment-masked) class body,
|
|
228
|
+
// so only TOP-LEVEL class members flag: a same-named object-literal
|
|
229
|
+
// shorthand or function expression nested inside a method body is a
|
|
230
|
+
// different object's property and shadows nothing.
|
|
231
|
+
const depth = new Int32Array(body.length);
|
|
232
|
+
{
|
|
233
|
+
let d = 0;
|
|
234
|
+
for (let i = 0; i < body.length; i++) {
|
|
235
|
+
if (body[i] === '{') { depth[i] = d; d++; }
|
|
236
|
+
else if (body[i] === '}') { d--; depth[i] = d; }
|
|
237
|
+
else depth[i] = d;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const name of NATIVE_MEMBERS) {
|
|
241
|
+
// Two shadowed-definition shapes, both instance-level: the method /
|
|
242
|
+
// accessor form (`remove() {`, `get remove() {`), and the class-field
|
|
243
|
+
// FUNCTION form (`remove = () => {}` / `= function () {}`), which the
|
|
244
|
+
// connect-time interception install overwrites just the same. A
|
|
245
|
+
// `static` member lives on the constructor and shadows nothing.
|
|
246
|
+
const methodRe = new RegExp(
|
|
247
|
+
`(?:^|[\\s;}])((?:static\\s+)?)(?:async\\s+)?(?:get\\s+|set\\s+)?(${name})\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`,
|
|
248
|
+
'g',
|
|
249
|
+
);
|
|
250
|
+
const fieldRe = new RegExp(
|
|
251
|
+
`(?:^|[\\s;}])((?:static\\s+)?)(${name})\\s*=\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*(?::[^=]*)?=>|[\\w$]+\\s*=>)`,
|
|
252
|
+
'g',
|
|
253
|
+
);
|
|
254
|
+
let flagged = false;
|
|
255
|
+
for (const re of [methodRe, fieldRe]) {
|
|
256
|
+
let m;
|
|
257
|
+
while (!flagged && (m = re.exec(body)) !== null) {
|
|
258
|
+
if (m[1]) continue; // static: not shadowed
|
|
259
|
+
const nameIdx = m.index + m[0].indexOf(m[2]);
|
|
260
|
+
if (depth[nameIdx] !== 0) continue; // nested: another object's property
|
|
261
|
+
flagged = true;
|
|
262
|
+
violations.push({
|
|
263
|
+
rule: 'no-shadowed-native-member',
|
|
264
|
+
file: rel,
|
|
265
|
+
message: `Component member \`${name}\` shadows the native DOM method WebJs instruments for the slot API, so it silently never runs (the native method wins) and TypeScript does not catch it.`,
|
|
266
|
+
fix: `Rename the member to a non-native name (e.g. \`${name}Row()\` / \`${name}Item()\`) and update its call sites.`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (flagged) break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import-graph rules: what a browser-bound module may reach, whether a local
|
|
3
|
+
* specifier resolves, and whether a bound form action can answer a POST.
|
|
4
|
+
*
|
|
5
|
+
* Lifted verbatim out of checkConventions, which held all twenty rules inline
|
|
6
|
+
* in one 900-line function. Each rule keeps its original comment header, its
|
|
7
|
+
* logic, and its position in the run order.
|
|
8
|
+
*/
|
|
9
|
+
import { relative } from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
redactStringsAndTemplates, redactToPlaceholders, classifyActionHole,
|
|
12
|
+
} from '../js-scan.js';
|
|
13
|
+
import { resolveImport } from '../module-graph.js';
|
|
14
|
+
import {
|
|
15
|
+
hasUseServerDirective, enumerableExports, importedLocalNames, importedValueNames,
|
|
16
|
+
} from './helpers.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {import('./rules.js').Violation} Violation
|
|
20
|
+
* @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rule: `no-missing-local-import`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} appDir absolute path to the app root
|
|
27
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
28
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
31
|
+
export function checkNoMissingLocalImport(appDir, files, violations) {
|
|
32
|
+
// --- Rule: no-missing-local-import ---
|
|
33
|
+
// 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,
|
|
35
|
+
// so `check` can stay green while `typecheck` is red (a dropped schema table
|
|
36
|
+
// orphaning a gallery module is the motivating dogfood case). Conservative by
|
|
37
|
+
// construction: only app-internal specifiers resolving to a known app file,
|
|
38
|
+
// only named value imports, and only when the target's exports are fully
|
|
39
|
+
// enumerable (see enumerableExports / importedValueNames).
|
|
40
|
+
{
|
|
41
|
+
// Fully-blanked view (blankStrings=true): string AND template AND comment
|
|
42
|
+
// bodies blank to spaces, position-preserving. The default `scan` keeps
|
|
43
|
+
// plain-string bodies VERBATIM (so callers can read `register('tag')`),
|
|
44
|
+
// which would let an `import`/`export` inside a string be matched, so use
|
|
45
|
+
// the fully-blanked view for both the export map and the import scan. The
|
|
46
|
+
// real specifier is read back from `content` at the same (length-preserved)
|
|
47
|
+
// offset.
|
|
48
|
+
const maskedByAbs = new Map();
|
|
49
|
+
for (const f of files) maskedByAbs.set(f.abs, redactStringsAndTemplates(f.content, true));
|
|
50
|
+
const exportsByAbs = new Map();
|
|
51
|
+
for (const f of files) exportsByAbs.set(f.abs, enumerableExports(maskedByAbs.get(f.abs)));
|
|
52
|
+
// `import\s+` excludes `import.meta` and a dynamic `import(`. The clause is
|
|
53
|
+
// `[^'";]*?` so it cannot swallow a side-effect `import '...'` (its quote) or
|
|
54
|
+
// bridge across a `;` into the next statement's `from`.
|
|
55
|
+
const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
|
|
56
|
+
for (const { abs, rel, content } of files) {
|
|
57
|
+
const masked = maskedByAbs.get(abs);
|
|
58
|
+
reImport.lastIndex = 0;
|
|
59
|
+
let m;
|
|
60
|
+
while ((m = reImport.exec(masked))) {
|
|
61
|
+
const quote = m[2];
|
|
62
|
+
const specStart = reImport.lastIndex; // just past the opening quote
|
|
63
|
+
const specEnd = content.indexOf(quote, specStart);
|
|
64
|
+
if (specEnd < 0) continue;
|
|
65
|
+
const spec = content.slice(specStart, specEnd);
|
|
66
|
+
if (!/^(?:\.|#)/.test(spec)) continue; // app-internal only (relative or #alias)
|
|
67
|
+
const names = importedValueNames(m[1]);
|
|
68
|
+
if (!names || names.length === 0) continue;
|
|
69
|
+
const target = resolveImport(spec, abs, appDir);
|
|
70
|
+
const exp = exportsByAbs.get(target);
|
|
71
|
+
if (exp == null) continue; // not an app file, or exports not enumerable
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
if (exp.has(name)) continue;
|
|
74
|
+
violations.push({
|
|
75
|
+
rule: 'no-missing-local-import',
|
|
76
|
+
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.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rule: `form-action-not-a-get-action`.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} appDir absolute path to the app root
|
|
90
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
91
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
92
|
+
* @returns {void}
|
|
93
|
+
*/
|
|
94
|
+
export function checkFormActionNotAGetAction(appDir, files, violations) {
|
|
95
|
+
// --- Rule: form-action-not-a-get-action (#1155) ---
|
|
96
|
+
// `<form action=${fn}>` binds a server action to a POST submission. An action
|
|
97
|
+
// declaring `method = 'GET'` cannot serve one: it is CSRF-exempt and takes its
|
|
98
|
+
// args in the url, so the dispatcher answers 405. Catch it at edit time.
|
|
99
|
+
{
|
|
100
|
+
// A `'use server'` file's declared method, or absent when it declares none.
|
|
101
|
+
const methodByAbs = new Map();
|
|
102
|
+
for (const f of files) {
|
|
103
|
+
if (!/\.server\.m?[jt]s$/.test(f.rel)) continue;
|
|
104
|
+
if (!hasUseServerDirective(f.content)) continue;
|
|
105
|
+
const m = /\bexport\s+const\s+method\s*(?::[^=]*)?=\s*['"`]([A-Za-z]+)['"`]/.exec(f.scan);
|
|
106
|
+
if (m) methodByAbs.set(f.abs, m[1].toUpperCase());
|
|
107
|
+
}
|
|
108
|
+
if (methodByAbs.size) {
|
|
109
|
+
const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
|
|
110
|
+
for (const { abs, rel, content } of files) {
|
|
111
|
+
// PLACEHOLDER redaction, not either mask, and the choice is forced.
|
|
112
|
+
// The default mask blanks a tagged template outright, so a real
|
|
113
|
+
// `html`<form action=${fn}>`` disappears and the rule could never fire;
|
|
114
|
+
// the fully-blanked mask does the same. Placeholder mode keeps `${...}`
|
|
115
|
+
// holes as REAL code while turning each literal BODY into one opaque
|
|
116
|
+
// `__STR_n__` token, so a live binding is readable and a `<form
|
|
117
|
+
// action=${x}>` shown as text inside a docs sample is a single token
|
|
118
|
+
// with no hole in it. That distinction is the whole carve-out: the
|
|
119
|
+
// framework's own website renders this exact shape as a code sample.
|
|
120
|
+
const { redacted, literals } = redactToPlaceholders(content);
|
|
121
|
+
// Both binding shapes count the same here: `action=` on a `<form>`
|
|
122
|
+
// (#1155) and `formaction=` on a `<button>` / `<input>` submitter
|
|
123
|
+
// (#1207) each put the named action behind a form submission, and a
|
|
124
|
+
// GET-declared action cannot serve either. `classifyActionHole` owns
|
|
125
|
+
// the tag-and-attribute pairing (js-scan.js).
|
|
126
|
+
const bound = new Set();
|
|
127
|
+
for (const m of redacted.matchAll(/__STR_(\d+)__\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
|
|
128
|
+
if (!classifyActionHole(literals[Number(m[1])] || '')) continue;
|
|
129
|
+
bound.add(m[2]);
|
|
130
|
+
}
|
|
131
|
+
if (!bound.size) continue;
|
|
132
|
+
reImport.lastIndex = 0;
|
|
133
|
+
let im;
|
|
134
|
+
while ((im = reImport.exec(redacted))) {
|
|
135
|
+
// The specifier is a placeholder token in this view, so read the real
|
|
136
|
+
// one back out of `literals`.
|
|
137
|
+
const tok = /__STR_(\d+)__/.exec(redacted.slice(reImport.lastIndex, reImport.lastIndex + 24));
|
|
138
|
+
if (!tok) continue;
|
|
139
|
+
const spec = literals[Number(tok[1])] || '';
|
|
140
|
+
if (!/^(?:\.|#)/.test(spec)) continue;
|
|
141
|
+
const names = importedLocalNames(im[1]);
|
|
142
|
+
if (!names.length) continue;
|
|
143
|
+
const target = resolveImport(spec, abs, appDir);
|
|
144
|
+
// ONLY a GET is contradictory, and the rule must say exactly what the
|
|
145
|
+
// dispatcher does or it is a false positive. A browser form always
|
|
146
|
+
// submits as a POST, and the dispatcher runs a PUT / PATCH / DELETE
|
|
147
|
+
// action on that POST: the declared verb governs the RPC transport,
|
|
148
|
+
// not whether the function can serve a form. A GET is different in
|
|
149
|
+
// kind (CSRF-exempt, args in the url, no body), which is why it is
|
|
150
|
+
// the one the dispatcher answers with a 405.
|
|
151
|
+
const method = target ? methodByAbs.get(target) : undefined;
|
|
152
|
+
if (method !== 'GET') continue;
|
|
153
|
+
for (const { local, imported } of names) {
|
|
154
|
+
if (!bound.has(local)) continue;
|
|
155
|
+
violations.push({
|
|
156
|
+
rule: 'form-action-not-a-get-action',
|
|
157
|
+
file: rel,
|
|
158
|
+
message: `Binds \`${imported}\` to a form, but \`${spec}\` declares \`method = 'GET'\`. A GET action rides its arguments in the url and skips the CSRF check, so it cannot answer a form POST; the submission is a 405.`,
|
|
159
|
+
fix: `Drop the \`method\` export from \`${spec}\` (an action with no method is a POST, which is what a form submits), or bind an action that takes a POST.`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom-element registry rules: tag-name validity and uniqueness across the app.
|
|
3
|
+
*
|
|
4
|
+
* Lifted verbatim out of checkConventions, which held all twenty rules inline
|
|
5
|
+
* in one 900-line function. Each rule keeps its original comment header, its
|
|
6
|
+
* logic, and its position in the run order.
|
|
7
|
+
*/
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
isComponentFile,
|
|
11
|
+
} from './helpers.js';
|
|
12
|
+
import {
|
|
13
|
+
gitIgnoredSet,
|
|
14
|
+
} from './runner-support.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {import('./rules.js').Violation} Violation
|
|
18
|
+
* @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Rule: `tag-name-has-hyphen`.
|
|
24
|
+
*
|
|
25
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
26
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
27
|
+
* @returns {void}
|
|
28
|
+
*/
|
|
29
|
+
export function checkTagNameHasHyphen(files, violations) {
|
|
30
|
+
// --- Rule: tag-name-has-hyphen ---
|
|
31
|
+
{
|
|
32
|
+
for (const { rel, scan } of files) {
|
|
33
|
+
if (!isComponentFile(rel)) continue;
|
|
34
|
+
// Use redacted source. A `register('tag')` call inside a
|
|
35
|
+
// TAGGED template literal (docs-page code example) is blanked.
|
|
36
|
+
// Calls at top level keep their structure AND their string
|
|
37
|
+
// argument. Quote style can be ', ", or ` (untagged backtick
|
|
38
|
+
// literals survive the redactor, like single/double-quote
|
|
39
|
+
// strings).
|
|
40
|
+
const patterns = [
|
|
41
|
+
// Class.register('tag') / register("tag") / register(`tag`)
|
|
42
|
+
/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
43
|
+
// customElements.define('tag', Class) and quote variants
|
|
44
|
+
/\bcustomElements\.define\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
45
|
+
];
|
|
46
|
+
for (const re of patterns) {
|
|
47
|
+
let match;
|
|
48
|
+
while ((match = re.exec(scan)) !== null) {
|
|
49
|
+
const tagName = match[2];
|
|
50
|
+
if (!tagName.includes('-')) {
|
|
51
|
+
violations.push({
|
|
52
|
+
rule: 'tag-name-has-hyphen',
|
|
53
|
+
file: rel,
|
|
54
|
+
message: `Custom element tag "${tagName}" must contain a hyphen`,
|
|
55
|
+
fix: `Rename to a hyphenated tag name, e.g. "app-${tagName}" or "${tagName}-element"`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Rule: `no-duplicate-tag`.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} appDir absolute path to the app root
|
|
68
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
69
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
70
|
+
* @returns {Promise<void>}
|
|
71
|
+
*/
|
|
72
|
+
export async function checkNoDuplicateTag(appDir, files, violations) {
|
|
73
|
+
// --- Rule: no-duplicate-tag ---
|
|
74
|
+
// Two registrations of the SAME tag string anywhere in the app resolve
|
|
75
|
+
// inconsistently at runtime (SSR last-wins, browser first-wins), so flag
|
|
76
|
+
// every colliding site naming the others. Scans EVERY source file, not just
|
|
77
|
+
// components/, because a duplicate is a runtime hazard regardless of where
|
|
78
|
+
// the register/define call lives (a page, a lib, a module can register a
|
|
79
|
+
// tag too); this keeps the rule in lockstep with the editor's 9004
|
|
80
|
+
// diagnostic, which is likewise project-wide. Reuses the same
|
|
81
|
+
// register/define extraction as tag-name-has-hyphen, over the redacted
|
|
82
|
+
// source so a tag in a docs-page tagged-template example does not count.
|
|
83
|
+
// Only hyphenated tags are considered (a non-hyphenated tag is already
|
|
84
|
+
// flagged by tag-name-has-hyphen / invariant 3), matching the 9004 filter.
|
|
85
|
+
{
|
|
86
|
+
// Generated / gitignored files (e.g. a `webjs ui add`-regenerated
|
|
87
|
+
// `components/` dir) are not committed source the rule should police;
|
|
88
|
+
// counting them would flag a collision between a hand-written component
|
|
89
|
+
// and its generated copy. Skip anything git reports as ignored.
|
|
90
|
+
// Best-effort: a non-git project (or absent git) scans everything.
|
|
91
|
+
const ignored = await gitIgnoredSet(appDir, files.map((f) => f.rel));
|
|
92
|
+
/** @type {Map<string, string[]>} tag -> rel files that register it (with repeats) */
|
|
93
|
+
const tagSites = new Map();
|
|
94
|
+
const patterns = [
|
|
95
|
+
/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
96
|
+
/\bcustomElements\.define\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
97
|
+
];
|
|
98
|
+
for (const { scan, rel } of files) {
|
|
99
|
+
if (ignored.has(rel)) continue;
|
|
100
|
+
for (const re of patterns) {
|
|
101
|
+
let match;
|
|
102
|
+
while ((match = re.exec(scan)) !== null) {
|
|
103
|
+
const tagName = match[2];
|
|
104
|
+
if (!tagName.includes('-')) continue;
|
|
105
|
+
const arr = tagSites.get(tagName) || [];
|
|
106
|
+
arr.push(rel);
|
|
107
|
+
tagSites.set(tagName, arr);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const [tagName, sites] of tagSites) {
|
|
112
|
+
if (sites.length < 2) continue;
|
|
113
|
+
// Report once per DISTINCT file, naming the others.
|
|
114
|
+
for (const file of new Set(sites)) {
|
|
115
|
+
const others = [...new Set(sites)].filter((f) => f !== file);
|
|
116
|
+
const where = others.length
|
|
117
|
+
? `also registered in ${others.join(', ')}`
|
|
118
|
+
: 'registered more than once in this file';
|
|
119
|
+
violations.push({
|
|
120
|
+
rule: 'no-duplicate-tag',
|
|
121
|
+
file,
|
|
122
|
+
message: `Custom element tag "${tagName}" is registered more than once (${where}). A tag must be registered exactly once; the runtime resolves a duplicate inconsistently (SSR keeps the last registration, the browser keeps the first).`,
|
|
123
|
+
fix: `Rename one registration so each "${tagName}" is unique, e.g. "${tagName}-2".`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|