@webjsdev/server 0.8.63 → 0.8.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/index.d.ts +7 -2
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/router.js +11 -1
  30. package/src/ssr/client-router-flag.js +42 -0
  31. package/src/ssr/document.js +361 -0
  32. package/src/ssr/env-shim.js +51 -0
  33. package/src/ssr/escape.js +29 -0
  34. package/src/ssr/head.js +857 -0
  35. package/src/ssr/preloads.js +195 -0
  36. package/src/ssr/render.js +1481 -0
  37. package/src/ssr/responses.js +201 -0
  38. package/src/ssr.js +28 -2445
  39. package/src/vendor/audit.js +400 -0
  40. package/src/vendor/integrity.js +245 -0
  41. package/src/vendor/jspm.js +348 -0
  42. package/src/vendor/manifest.js +96 -0
  43. package/src/vendor/pins.js +892 -0
  44. package/src/vendor/providers.js +35 -0
  45. package/src/vendor/resolver.js +225 -0
  46. package/src/vendor/scanner.js +132 -0
  47. 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
+ }