@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.
Files changed (48) hide show
  1. package/index.d.ts +32 -3
  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/rate-limit.js +50 -5
  30. package/src/router.js +11 -1
  31. package/src/ssr/client-router-flag.js +42 -0
  32. package/src/ssr/document.js +361 -0
  33. package/src/ssr/env-shim.js +51 -0
  34. package/src/ssr/escape.js +29 -0
  35. package/src/ssr/head.js +857 -0
  36. package/src/ssr/preloads.js +195 -0
  37. package/src/ssr/render.js +1481 -0
  38. package/src/ssr/responses.js +201 -0
  39. package/src/ssr.js +28 -2445
  40. package/src/vendor/audit.js +400 -0
  41. package/src/vendor/integrity.js +245 -0
  42. package/src/vendor/jspm.js +348 -0
  43. package/src/vendor/manifest.js +96 -0
  44. package/src/vendor/pins.js +892 -0
  45. package/src/vendor/providers.js +35 -0
  46. package/src/vendor/resolver.js +225 -0
  47. package/src/vendor/scanner.js +132 -0
  48. 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
+ }