@webjsdev/server 0.8.62 → 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.
- package/index.d.ts +32 -3
- 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 +1 -1
- 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/rate-limit.js +50 -5
- 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,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
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing and template rules: what a route handler may throw, what a non-root
|
|
3
|
+
* layout may emit, and which holes a template may carry.
|
|
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
|
+
isServerActionFile, isComponentFile,
|
|
11
|
+
} from './helpers.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {import('./rules.js').Violation} Violation
|
|
15
|
+
* @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Rule: `no-redirect-in-api-route`.
|
|
21
|
+
*
|
|
22
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
23
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
24
|
+
* @returns {void}
|
|
25
|
+
*/
|
|
26
|
+
export function checkNoRedirectInApiRoute(files, violations) {
|
|
27
|
+
// --- Rule: no-redirect-in-api-route ---
|
|
28
|
+
// `redirect()` from `@webjsdev/core` throws a control-flow signal designed
|
|
29
|
+
// for the SSR page renderer. In a `route.ts` API handler it goes uncaught
|
|
30
|
+
// and produces a 500. API handlers must use `Response.redirect(url, 303)`
|
|
31
|
+
// instead. Page functions, layouts, and server actions may still use
|
|
32
|
+
// `redirect()` (caught by the SSR / action pipeline).
|
|
33
|
+
{
|
|
34
|
+
const ROUTE_FILE = /(?:^|\/)route\.m?[jt]s$/;
|
|
35
|
+
for (const { rel, scan } of files) {
|
|
36
|
+
if (!ROUTE_FILE.test(rel)) continue;
|
|
37
|
+
// `redirect` reaches the route file in one of two statically-visible ways:
|
|
38
|
+
// 1. A NAMED import from `@webjsdev/core` (possibly aliased):
|
|
39
|
+
// `import { redirect }`, `import { redirect as r }`, `import { …, redirect, … }`.
|
|
40
|
+
// 2. A NAMESPACE import then a member call:
|
|
41
|
+
// `import * as core from '@webjsdev/core'` then `core.redirect(...)`.
|
|
42
|
+
// The named case flags a bare `redirect(` call; the namespace case flags
|
|
43
|
+
// `<ns>.redirect(`. `Response.redirect(` and any other `obj.redirect(` are
|
|
44
|
+
// the standard API and stay fine. A `redirect()` thrown inside a
|
|
45
|
+
// '`use server`' action the route calls DIRECTLY (an uncaught 500) needs
|
|
46
|
+
// cross-file analysis and is left to the AST rework (#753).
|
|
47
|
+
const namedM = /\bimport\s+\{[^}]*\bredirect\b(?:\s+as\s+(\w+))?\s*[^}]*\}\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
|
|
48
|
+
const nsM = /\bimport\s+\*\s+as\s+(\w+)\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
|
|
49
|
+
// A file can carry BOTH a named `redirect` import AND a namespace import,
|
|
50
|
+
// so check every matcher independently (not mutually exclusive): a named
|
|
51
|
+
// import means a bare `<localName>(`, a namespace import means
|
|
52
|
+
// `<ns>.redirect(`. The `member` flag distinguishes the two so a bare
|
|
53
|
+
// named call can screen out `Response.redirect(` / `obj.redirect(`.
|
|
54
|
+
/** @type {Array<{ re: RegExp, member: boolean }>} */
|
|
55
|
+
const matchers = [];
|
|
56
|
+
if (namedM) {
|
|
57
|
+
const localName = namedM[1] || 'redirect';
|
|
58
|
+
matchers.push({ re: new RegExp(`(?<!\\.)\\b${localName}\\s*\\(`, 'g'), member: false });
|
|
59
|
+
}
|
|
60
|
+
if (nsM) {
|
|
61
|
+
matchers.push({ re: new RegExp(`\\b${nsM[1]}\\.redirect\\s*\\(`, 'g'), member: true });
|
|
62
|
+
}
|
|
63
|
+
let flagged = false;
|
|
64
|
+
for (const { re, member } of matchers) {
|
|
65
|
+
if (flagged) break;
|
|
66
|
+
let m;
|
|
67
|
+
while ((m = re.exec(scan)) !== null) {
|
|
68
|
+
if (!member) {
|
|
69
|
+
// Screen out `Response.redirect(` / `someObj.redirect(` sharing the
|
|
70
|
+
// local name: a preceding member-access dot means it is not the import.
|
|
71
|
+
const before = scan.slice(Math.max(0, m.index - 20), m.index);
|
|
72
|
+
if (/\w\.$/.test(before)) continue;
|
|
73
|
+
}
|
|
74
|
+
violations.push({
|
|
75
|
+
rule: 'no-redirect-in-api-route',
|
|
76
|
+
file: rel,
|
|
77
|
+
message:
|
|
78
|
+
`\`redirect()\` from \`@webjsdev/core\` throws a control-flow signal for the SSR page renderer; in a \`route.ts\` handler it goes uncaught and returns a 500.`,
|
|
79
|
+
fix: `Use \`Response.redirect(url, 303)\` for external redirects, or return a 3xx Response directly. The \`redirect()\` sentinel is only valid in page functions, layouts, and server actions (where the SSR pipeline catches it).`,
|
|
80
|
+
});
|
|
81
|
+
flagged = true; // one violation per file is enough
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Rule: `no-interpolation-in-raw-text-element`.
|
|
91
|
+
*
|
|
92
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
93
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
94
|
+
* @returns {void}
|
|
95
|
+
*/
|
|
96
|
+
export function checkNoInterpolationInRawTextElement(files, violations) {
|
|
97
|
+
// --- Rule: no-interpolation-in-raw-text-element ---
|
|
98
|
+
// A `${...}` hole inside a `<style>` or `<script>` element in an `html`
|
|
99
|
+
// template is an SSR/client asymmetry: `renderToString` emits it, but the
|
|
100
|
+
// client parser drops a raw-text hole as a `noop` (the compile cache is keyed
|
|
101
|
+
// on the static strings, so a per-render value cannot be baked in), so the
|
|
102
|
+
// element paints at SSR then wipes to empty on hydration.
|
|
103
|
+
//
|
|
104
|
+
// Scoped to COMPONENTS. The drop only happens on the CLIENT renderer, which
|
|
105
|
+
// runs for components (hydration + re-render). Pages and layouts render
|
|
106
|
+
// server-only (never hydrate), so a page's `<style>${STYLES.text}</style>` is
|
|
107
|
+
// a legitimate, taught pattern and must NOT be flagged. Scan raw source with
|
|
108
|
+
// comments stripped (the tag text lives in a template string, which the
|
|
109
|
+
// redacted `scan` view blanks). One violation per file.
|
|
110
|
+
{
|
|
111
|
+
for (const { rel, content } of files) {
|
|
112
|
+
// Only files that define a hydrating custom element. A page/layout that
|
|
113
|
+
// interpolates a `css` result into a `<style>` is server-only and fine.
|
|
114
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(content)) continue;
|
|
115
|
+
const stripped = content
|
|
116
|
+
.replace(/\/\/.*$/gm, '')
|
|
117
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
118
|
+
for (const tag of ['style', 'script']) {
|
|
119
|
+
// `<tag ...> ... ${ ... </tag>`, where the hole sits before the close
|
|
120
|
+
// tag. The negative lookahead keeps the match from crossing `</tag>`.
|
|
121
|
+
const re = new RegExp(
|
|
122
|
+
`<${tag}\\b[^>]*>(?:(?!<\\/${tag}>)[\\s\\S])*?\\$\\{(?:(?!<\\/${tag}>)[\\s\\S])*?<\\/${tag}>`,
|
|
123
|
+
'i',
|
|
124
|
+
);
|
|
125
|
+
if (re.test(stripped)) {
|
|
126
|
+
violations.push({
|
|
127
|
+
rule: 'no-interpolation-in-raw-text-element',
|
|
128
|
+
file: rel,
|
|
129
|
+
message: `An interpolation (\`\${...}\`) sits inside a <${tag}> element in an html template. The server renderer emits it but the client renderer drops it, so it paints at SSR then wipes to empty on hydration.`,
|
|
130
|
+
fix:
|
|
131
|
+
tag === 'style'
|
|
132
|
+
? `Move the CSS out of the raw-text hole: use \`static styles\` (shadow DOM) or a \`css\` template for a component, or put page CSS in the layout. Static \`<style>...</style>\` with no \`\${}\` is fine.`
|
|
133
|
+
: `Build the script body outside the raw-text element (set attributes/properties via bindings, or compute the value before the template). Static \`<script>...</script>\` with no \`\${}\` is fine.`,
|
|
134
|
+
});
|
|
135
|
+
break; // one violation per file is enough
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Rule: `no-server-env-in-components`.
|
|
144
|
+
*
|
|
145
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
146
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
147
|
+
* @returns {void}
|
|
148
|
+
*/
|
|
149
|
+
export function checkNoServerEnvInComponents(files, violations) {
|
|
150
|
+
// --- Rule: no-server-env-in-components ---
|
|
151
|
+
// Catches `process.env.X` reads in component files where X is not a
|
|
152
|
+
// WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
|
|
153
|
+
// two categories to the browser; any other read either leaks a secret
|
|
154
|
+
// into the SSR'd HTML or reads as undefined after hydration.
|
|
155
|
+
{
|
|
156
|
+
for (const { abs, rel, content } of files) {
|
|
157
|
+
if (!isComponentFile(rel)) continue;
|
|
158
|
+
if (isServerActionFile(abs, content)) continue;
|
|
159
|
+
|
|
160
|
+
const re = /\bprocess\.env\.([A-Z][A-Z0-9_]*)\b/g;
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
let m;
|
|
163
|
+
while ((m = re.exec(content)) !== null) {
|
|
164
|
+
const name = m[1];
|
|
165
|
+
if (name.startsWith('WEBJS_PUBLIC_')) continue;
|
|
166
|
+
if (name === 'NODE_ENV') continue;
|
|
167
|
+
if (seen.has(name)) continue;
|
|
168
|
+
seen.add(name);
|
|
169
|
+
violations.push({
|
|
170
|
+
rule: 'no-server-env-in-components',
|
|
171
|
+
file: rel,
|
|
172
|
+
message: `Component reads process.env.${name}; server-only env vars must not be read in components (would leak into SSR'd HTML and read as undefined after hydration)`,
|
|
173
|
+
fix: `Either rename to WEBJS_PUBLIC_${name} if the value is intended for the browser, or read process.env.${name} in a page function / server action / middleware and pass a derived value to the component as an attribute.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Rule: `shell-in-non-root-layout`.
|
|
182
|
+
*
|
|
183
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
184
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
185
|
+
* @returns {void}
|
|
186
|
+
*/
|
|
187
|
+
export function checkShellInNonRootLayout(files, violations) {
|
|
188
|
+
// --- Rule: shell-in-non-root-layout ---
|
|
189
|
+
// Only app/layout.{js,ts} may write <!doctype>/<html>/<head>/<body>. The
|
|
190
|
+
// framework auto-emits the shell around the whole composition; a nested
|
|
191
|
+
// shell ends up duplicated and silently dropped by the HTML parser.
|
|
192
|
+
{
|
|
193
|
+
// Root layout = exactly "app/layout.js" or "app/layout.ts".
|
|
194
|
+
const ROOT_LAYOUT = /^app\/layout\.(?:js|mjs|ts|mts)$/;
|
|
195
|
+
// Any other layout or page under app/ (including pages, nested layouts).
|
|
196
|
+
const LAYOUT_OR_PAGE = /^app\/(?:.+\/)?(?:layout|page)\.(?:js|mjs|ts|mts)$/;
|
|
197
|
+
// Shell tags. Case-insensitive, allow whitespace, allow attributes for <html>/<body>.
|
|
198
|
+
const SHELL_RE = /<!doctype\b|<html\b|<head\b|<body\b/i;
|
|
199
|
+
for (const { rel, content } of files) {
|
|
200
|
+
if (ROOT_LAYOUT.test(rel)) continue;
|
|
201
|
+
if (!LAYOUT_OR_PAGE.test(rel)) continue;
|
|
202
|
+
// Strip line comments + /* … */ block comments + ` ` string-template
|
|
203
|
+
// tag content is fine: we're looking at the literal HTML in the
|
|
204
|
+
// returned `html` template, which won't be inside a code comment.
|
|
205
|
+
// A naive substring scan is good enough; false positives only when
|
|
206
|
+
// someone genuinely embeds `<html>` inside a string literal that
|
|
207
|
+
// isn't a layout shell (rare and probably an honest code smell).
|
|
208
|
+
const stripped = content
|
|
209
|
+
.replace(/\/\/.*$/gm, '')
|
|
210
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
211
|
+
const m = stripped.match(SHELL_RE);
|
|
212
|
+
if (m) {
|
|
213
|
+
violations.push({
|
|
214
|
+
rule: 'shell-in-non-root-layout',
|
|
215
|
+
file: rel,
|
|
216
|
+
message:
|
|
217
|
+
`Non-root layout/page contains ${m[0]}: only the root layout (app/layout.{js,ts}) may write the shell. The framework auto-emits <!doctype>/<html>/<head>/<body> around the whole composition; a nested shell ends up duplicated and dropped by the HTML parser.`,
|
|
218
|
+
fix:
|
|
219
|
+
'Remove the <!doctype>/<html>/<head>/<body> wrapper from this file. Use the `metadata` export for <title>/<meta>/og/twitter, return inline <link>/<style>/<script> for head-bound resources (they auto-hoist), and put any `<html lang>` / `<body class>` overrides in app/layout.{js,ts} instead.',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript rules. WebJs strips types rather than compiling them, so
|
|
3
|
+
* non-erasable syntax is a 500 at strip time, not a build error.
|
|
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 { readFile } from 'node:fs/promises';
|
|
10
|
+
import { join, relative, sep } from 'node:path';
|
|
11
|
+
import { walk } from '../fs-walk.js';
|
|
12
|
+
import {
|
|
13
|
+
redactStringsAndTemplates,
|
|
14
|
+
} from '../js-scan.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: `erasable-typescript-only`.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} appDir absolute path to the app root
|
|
26
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
export async function checkErasableTypescriptOnly(appDir, violations) {
|
|
30
|
+
// --- Rule: erasable-typescript-only ---
|
|
31
|
+
// The dev server's type-stripper is Node's built-in
|
|
32
|
+
// module.stripTypeScriptTypes, which rejects non-erasable TS (enum,
|
|
33
|
+
// namespace with values, constructor parameter properties, legacy
|
|
34
|
+
// decorators, `import = require`). There is no fallback: non-erasable
|
|
35
|
+
// syntax is rejected at request time with a 500. Enforce TS-side
|
|
36
|
+
// rejection of those patterns via `compilerOptions.erasableSyntaxOnly:
|
|
37
|
+
// true` in tsconfig.json so violations surface as red squiggles in
|
|
38
|
+
// the editor before they ever hit the dev server. The companion
|
|
39
|
+
// no-non-erasable-typescript rule (below) catches violations even if
|
|
40
|
+
// the tsconfig flag is unset.
|
|
41
|
+
{
|
|
42
|
+
let tsconfigContent = null;
|
|
43
|
+
try {
|
|
44
|
+
tsconfigContent = await readFile(join(appDir, 'tsconfig.json'), 'utf8');
|
|
45
|
+
} catch {
|
|
46
|
+
// No tsconfig.json (pure JS app). Skip the rule.
|
|
47
|
+
}
|
|
48
|
+
if (tsconfigContent != null) {
|
|
49
|
+
let parsed = null;
|
|
50
|
+
try {
|
|
51
|
+
const stripped = tsconfigContent
|
|
52
|
+
.replace(/\/\/.*$/gm, '')
|
|
53
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
54
|
+
.replace(/,(\s*[}\]])/g, '$1');
|
|
55
|
+
parsed = JSON.parse(stripped);
|
|
56
|
+
} catch {
|
|
57
|
+
parsed = null;
|
|
58
|
+
}
|
|
59
|
+
const compilerOptions = parsed && typeof parsed === 'object' ? parsed.compilerOptions : null;
|
|
60
|
+
const flag = compilerOptions && typeof compilerOptions === 'object' ? compilerOptions.erasableSyntaxOnly : undefined;
|
|
61
|
+
if (flag !== true) {
|
|
62
|
+
violations.push({
|
|
63
|
+
rule: 'erasable-typescript-only',
|
|
64
|
+
file: 'tsconfig.json',
|
|
65
|
+
message:
|
|
66
|
+
flag === false
|
|
67
|
+
? '`compilerOptions.erasableSyntaxOnly` is `false`. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators) fails at strip time and the dev server returns a 500. webjs is buildless end-to-end and has no bundler fallback; turn the flag on so the TypeScript compiler catches non-erasable constructs as red squiggles at edit time.'
|
|
68
|
+
: '`compilerOptions.erasableSyntaxOnly` is not set. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Setting this flag makes the TypeScript compiler flag non-erasable syntax as a red squiggle in the editor instead of letting it silently slip through to a 500 at runtime.',
|
|
69
|
+
fix:
|
|
70
|
+
'Set `"erasableSyntaxOnly": true` under `compilerOptions` in tsconfig.json. Replace any existing `enum` declarations with `const X = { ... } as const` plus a `type X = typeof X[keyof typeof X]` union. Replace constructor parameter properties with explicit field declarations + assignments.',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Rule: `no-non-erasable-typescript`.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} appDir absolute path to the app root
|
|
81
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
82
|
+
* @returns {Promise<void>}
|
|
83
|
+
*/
|
|
84
|
+
export async function checkNoNonErasableTypescript(appDir, violations) {
|
|
85
|
+
// --- Rule: no-non-erasable-typescript ---
|
|
86
|
+
// Scans .ts source for the four non-erasable TypeScript constructs
|
|
87
|
+
// that the runtime stripper rejects. Complement to
|
|
88
|
+
// erasable-typescript-only: the flag check catches the case where
|
|
89
|
+
// the user opts into the tsconfig flag; this scan catches the
|
|
90
|
+
// case where the flag is missing OR the user has bypassed it and
|
|
91
|
+
// written offending syntax anyway. Both rules ship enabled by
|
|
92
|
+
// default so violators get the strongest signal possible.
|
|
93
|
+
{
|
|
94
|
+
/** @type {Array<{ name: string, regex: RegExp, fix: string }>} */
|
|
95
|
+
const NON_ERASABLE_PATTERNS = [
|
|
96
|
+
{
|
|
97
|
+
name: 'enum',
|
|
98
|
+
// Matches `enum X {`, `export enum X {`, `const enum X {`,
|
|
99
|
+
// `declare enum X {`. Requires uppercase first letter on the
|
|
100
|
+
// identifier to avoid matching variables literally named "enum"
|
|
101
|
+
// in user code (rare but possible).
|
|
102
|
+
regex: /^[ \t]*(?:export[ \t]+)?(?:declare[ \t]+)?(?:const[ \t]+)?enum[ \t]+[A-Z]\w*[ \t]*\{/m,
|
|
103
|
+
fix: 'Replace `enum Foo { A, B }` with `const Foo = { A: "A", B: "B" } as const; type Foo = typeof Foo[keyof typeof Foo];`.',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'namespace with values',
|
|
107
|
+
// Matches `namespace Foo { ... <value statement> ... }` at top
|
|
108
|
+
// level. Type-only namespaces (which ARE erasable) won't contain
|
|
109
|
+
// `let|const|var|function|class` as statements, so this catches
|
|
110
|
+
// only the value-carrying form. False positives possible for
|
|
111
|
+
// type-only namespaces that contain those words in type aliases;
|
|
112
|
+
// accept this as a soft warning.
|
|
113
|
+
regex: /^[ \t]*(?:export[ \t]+)?namespace[ \t]+\w+[ \t]*\{[\s\S]*?\b(?:let|const|var|function|class)\b/m,
|
|
114
|
+
fix: 'Replace `namespace Foo { export const x = 1 }` with `export const Foo = { x: 1 } as const;` or split the contents into separate modules.',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'constructor parameter property',
|
|
118
|
+
// Matches `constructor(public x: T)`, `constructor(private foo, ...)`,
|
|
119
|
+
// `constructor(readonly bar)`. Looks for one of the four access
|
|
120
|
+
// modifiers immediately followed by an identifier inside the
|
|
121
|
+
// constructor's parameter list.
|
|
122
|
+
regex: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/,
|
|
123
|
+
fix: 'Replace `constructor(public x: number)` with `x: number; constructor(x: number) { this.x = x; }`.',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'import = require',
|
|
127
|
+
// TypeScript-style CommonJS import. Catches `import foo =
|
|
128
|
+
// require("bar")` and `export import foo = require("bar")`.
|
|
129
|
+
regex: /^[ \t]*(?:export[ \t]+)?import[ \t]+\w+[ \t]*=[ \t]*require[ \t]*\(/m,
|
|
130
|
+
fix: 'Replace `import foo = require("bar")` with `import * as foo from "bar"` or `import { something } from "bar"`.',
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
// Walk every .ts / .mts file under appDir, skipping node_modules,
|
|
135
|
+
// build outputs, version control, and the framework's own private
|
|
136
|
+
// folders. Match the conventional excludes that fs-walk.js's caller
|
|
137
|
+
// contract expects.
|
|
138
|
+
for await (const abs of walk(appDir, (p) => /\.m?ts$/.test(p))) {
|
|
139
|
+
// Skip anything inside node_modules or common build / cache dirs.
|
|
140
|
+
const relPath = relative(appDir, abs);
|
|
141
|
+
if (
|
|
142
|
+
relPath.includes('node_modules' + sep) ||
|
|
143
|
+
relPath.startsWith('dist' + sep) ||
|
|
144
|
+
relPath.startsWith('build' + sep) ||
|
|
145
|
+
relPath.startsWith('.next' + sep) ||
|
|
146
|
+
relPath.startsWith('.git' + sep) ||
|
|
147
|
+
relPath.split(sep).some((s) => s.startsWith('_'))
|
|
148
|
+
) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
let content;
|
|
152
|
+
try {
|
|
153
|
+
content = await readFile(abs, 'utf8');
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// Redact comments, string contents, and template-literal bodies
|
|
158
|
+
// so docs-page code examples like `<pre>enum Direction { ... }</pre>`
|
|
159
|
+
// inside `html\`...\`` template literals don't trip the rule.
|
|
160
|
+
// The redactor preserves line + column so the reported line
|
|
161
|
+
// number still maps to the right place in the original.
|
|
162
|
+
const scan = redactStringsAndTemplates(content);
|
|
163
|
+
for (const { name, regex, fix } of NON_ERASABLE_PATTERNS) {
|
|
164
|
+
const m = scan.match(regex);
|
|
165
|
+
if (m && typeof m.index === 'number') {
|
|
166
|
+
const line = content.slice(0, m.index).split('\n').length;
|
|
167
|
+
violations.push({
|
|
168
|
+
rule: 'no-non-erasable-typescript',
|
|
169
|
+
file: relPath,
|
|
170
|
+
message: `Non-erasable TypeScript construct (${name}) detected at line ${line}. The framework's type-stripper rejects this at request time with a 500.`,
|
|
171
|
+
fix,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|